From 6dc6d6a95400a8e16773b9282a9a23247c3d5245 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:24:46 +0200 Subject: [PATCH 01/28] Add experimental lib_logging XDK module (SLF4J-shaped, injection-first) Introduce lib_logging as a new XDK lib that gives Ecstasy a logging API intentionally familiar to SLF4J 2.x users (Logger, Level, Marker, MDC, LoggingEventBuilder, parameterized {} messages, throwable promotion) and acquired the Ecstasy way via @Inject Logger logger;. The default sink writes to the platform Console; LogSink is the API/impl boundary so a future configuration-driven backend (lib_logging_logback) can drop in without touching caller code. The Ecstasy module compiles and ships in xdk:installDist as logging.xtc. Eighteen unit tests under lib_logging/src/test/x/LoggingTest cover level ordering, per-level emission, level-check fast path, marker semantics + propagation, the fluent event builder, and a custom CountingSink. All pass via :xdk:lib-logging:testXtc. Marker is fully XDK-idiomatic: extends Freezable + Stringable + Hashable, with BasicMarker implementing freeze that recurses into child references. LogEvent is a const that carries the full Marker reference. Build wiring: xdk/settings.gradle.kts registers lib_logging as a subproject; gradle/libs.versions.toml adds xdk-logging; xdk/build.gradle.kts pulls it into the distribution alongside the other libs. The runtime side (RTLogger.java in javatools_jitbridge plus a wildcard-name entry in nMainInjector.addNativeResources) is intentionally not included in this commit; until it lands @Inject Logger logger; does not resolve, and user code uses new BasicLogger(name, sink) directly. Open questions and the round-trip implementation plan are tracked in doc/logging/. Documentation under doc/logging/ covers: master plan, design, SLF4J parity mapping, side-by-side ecstasy-vs-java examples, structured logging, lazy logging, alternative slog-shaped design, custom sink guide, logback integration sketch, native bridge feasibility study, platform/examples migration plan, XDK alignment audit, and open questions. A working @Inject Logger example doc plus a manualTest at manualTests/src/main/x/ TestLogger.x complete the picture. Also includes the SLF4J provider guide (slf4j_full_guide.md) the design draws from, referenced from the doc tree. --- doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md | 311 +++++++++++++++ doc/logging/CUSTOM_SINKS.md | 270 +++++++++++++ doc/logging/DESIGN.md | 153 +++++++ doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md | 346 ++++++++++++++++ doc/logging/INJECTED_LOGGER_EXAMPLE.md | 204 ++++++++++ doc/logging/LAZY_LOGGING.md | 243 +++++++++++ doc/logging/LOGBACK_INTEGRATION.md | 288 +++++++++++++ doc/logging/NATIVE_BRIDGE.md | 227 +++++++++++ doc/logging/OPEN_QUESTIONS.md | 184 +++++++++ doc/logging/PLAN.md | 123 ++++++ .../PLATFORM_AND_EXAMPLES_ADAPTATION.md | 272 +++++++++++++ doc/logging/README.md | 166 ++++++++ doc/logging/SLF4J_PARITY.md | 117 ++++++ doc/logging/STRUCTURED_LOGGING.md | 217 ++++++++++ doc/logging/WHY_SLF4J_AND_INJECTION.md | 323 +++++++++++++++ doc/logging/XDK_ALIGNMENT.md | 202 ++++++++++ gradle/libs.versions.toml | 1 + lib_logging/README.md | 38 ++ lib_logging/build.gradle.kts | 10 + lib_logging/src/main/x/logging.x | 46 +++ .../src/main/x/logging/BasicEventBuilder.x | 91 +++++ lib_logging/src/main/x/logging/BasicLogger.x | 109 +++++ lib_logging/src/main/x/logging/BasicMarker.x | 85 ++++ .../src/main/x/logging/ConsoleLogSink.x | 64 +++ lib_logging/src/main/x/logging/Level.x | 31 ++ lib_logging/src/main/x/logging/LogEvent.x | 22 + lib_logging/src/main/x/logging/LogSink.x | 53 +++ lib_logging/src/main/x/logging/Logger.x | 94 +++++ .../src/main/x/logging/LoggerFactory.x | 39 ++ .../src/main/x/logging/LoggingEventBuilder.x | 73 ++++ lib_logging/src/main/x/logging/MDC.x | 63 +++ lib_logging/src/main/x/logging/Marker.x | 96 +++++ .../src/main/x/logging/MarkerFactory.x | 37 ++ .../src/main/x/logging/MemoryLogSink.x | 43 ++ .../src/main/x/logging/MessageFormatter.x | 32 ++ lib_logging/src/main/x/logging/NoopLogSink.x | 25 ++ lib_logging/src/test/x/LoggingTest.x | 18 + .../src/test/x/LoggingTest/CountingSink.x | 27 ++ .../src/test/x/LoggingTest/CountingSinkTest.x | 25 ++ .../src/test/x/LoggingTest/EmissionTest.x | 54 +++ .../test/x/LoggingTest/FluentBuilderTest.x | 61 +++ .../src/test/x/LoggingTest/LevelCheckTest.x | 55 +++ .../src/test/x/LoggingTest/LevelTest.x | 35 ++ .../src/test/x/LoggingTest/ListLogSink.x | 36 ++ .../src/test/x/LoggingTest/MarkerTest.x | 52 +++ manualTests/src/main/x/TestLogger.x | 77 ++++ slf4j_full_guide.md | 377 ++++++++++++++++++ xdk/build.gradle.kts | 1 + xdk/settings.gradle.kts | 1 + 49 files changed, 5517 insertions(+) create mode 100644 doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md create mode 100644 doc/logging/CUSTOM_SINKS.md create mode 100644 doc/logging/DESIGN.md create mode 100644 doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md create mode 100644 doc/logging/INJECTED_LOGGER_EXAMPLE.md create mode 100644 doc/logging/LAZY_LOGGING.md create mode 100644 doc/logging/LOGBACK_INTEGRATION.md create mode 100644 doc/logging/NATIVE_BRIDGE.md create mode 100644 doc/logging/OPEN_QUESTIONS.md create mode 100644 doc/logging/PLAN.md create mode 100644 doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md create mode 100644 doc/logging/README.md create mode 100644 doc/logging/SLF4J_PARITY.md create mode 100644 doc/logging/STRUCTURED_LOGGING.md create mode 100644 doc/logging/WHY_SLF4J_AND_INJECTION.md create mode 100644 doc/logging/XDK_ALIGNMENT.md create mode 100644 lib_logging/README.md create mode 100644 lib_logging/build.gradle.kts create mode 100644 lib_logging/src/main/x/logging.x create mode 100644 lib_logging/src/main/x/logging/BasicEventBuilder.x create mode 100644 lib_logging/src/main/x/logging/BasicLogger.x create mode 100644 lib_logging/src/main/x/logging/BasicMarker.x create mode 100644 lib_logging/src/main/x/logging/ConsoleLogSink.x create mode 100644 lib_logging/src/main/x/logging/Level.x create mode 100644 lib_logging/src/main/x/logging/LogEvent.x create mode 100644 lib_logging/src/main/x/logging/LogSink.x create mode 100644 lib_logging/src/main/x/logging/Logger.x create mode 100644 lib_logging/src/main/x/logging/LoggerFactory.x create mode 100644 lib_logging/src/main/x/logging/LoggingEventBuilder.x create mode 100644 lib_logging/src/main/x/logging/MDC.x create mode 100644 lib_logging/src/main/x/logging/Marker.x create mode 100644 lib_logging/src/main/x/logging/MarkerFactory.x create mode 100644 lib_logging/src/main/x/logging/MemoryLogSink.x create mode 100644 lib_logging/src/main/x/logging/MessageFormatter.x create mode 100644 lib_logging/src/main/x/logging/NoopLogSink.x create mode 100644 lib_logging/src/test/x/LoggingTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/CountingSink.x create mode 100644 lib_logging/src/test/x/LoggingTest/CountingSinkTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/EmissionTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/FluentBuilderTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/LevelCheckTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/LevelTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/ListLogSink.x create mode 100644 lib_logging/src/test/x/LoggingTest/MarkerTest.x create mode 100644 manualTests/src/main/x/TestLogger.x create mode 100644 slf4j_full_guide.md diff --git a/doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md b/doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md new file mode 100644 index 0000000000..524eadccbd --- /dev/null +++ b/doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md @@ -0,0 +1,311 @@ +# Alternative design — what `lib_logging` would look like as an `slog`-style library + +This is an **exploratory** document. It exists so we have a clear-eyed picture of what +we'd be giving up (and what we'd be gaining) if we modeled the Ecstasy logging library +on Go's `log/slog` instead of SLF4J. No skeleton code is being added to the repo for +this design; everything below is illustrative. + +The file is organised symmetrically with the SLF4J design so you can A/B them. + +## What `slog` is + +`slog` is the structured-logging package added to Go's standard library in Go 1.21. Its +shape diverges from SLF4J in three significant ways: + +1. **Structured first, free-form text second.** `slog` calls take a message followed by + alternating key/value pairs as the *primary* mechanism, not as an addendum: + `slog.Info("processed", "count", 42, "duration", elapsed)`. +2. **`Handler` instead of `Appender`/`Encoder`.** A handler is a single combined + "filter + render + ship" object. The standard library ships `TextHandler` and + `JSONHandler`. Custom handlers wrap or replace them. +3. **`Logger` is a value type, not a service.** Loggers are cheap to derive: `child := + logger.With("requestId", id)` returns a new logger that prepends those attributes to + every event. There is no `MDC`-style ambient context — context is lexical. + +These three together produce a quite different developer experience. + +## What `lib_logging` would look like as an slog-style library + +### Core types + +The user-facing facade collapses from "Logger plus Marker plus MDC plus Builder" down to +"Logger plus Attr". A sketch: + +```ecstasy +/** + * The slog-style logger. Holds an immutable list of pre-bound attributes plus a handler. + */ +const Logger(Attr[] attrs, Handler handler) { + + @RO Boolean traceEnabled.get() = handler.enabled(Trace, attrs); + @RO Boolean debugEnabled.get() = handler.enabled(Debug, attrs); + // ... + + void info (String message, Attr[] extra = []) = log(Info, message, extra); + void debug(String message, Attr[] extra = []) = log(Debug, message, extra); + void warn (String message, Attr[] extra = []) = log(Warn, message, extra); + void error(String message, Attr[] extra = []) = log(Error, message, extra); + void trace(String message, Attr[] extra = []) = log(Trace, message, extra); + + /** + * Derive a new logger with extra attributes pre-bound. + */ + Logger with(Attr... extra) { + return new Logger(attrs + extra, handler); + } + + /** + * Derive a new logger inside a *group*; child attributes are nested under `name` + * in structured output. + */ + Logger group(String name) { + return new Logger(attrs.add(GroupOpen(name)), handler); + } + + void log(Level level, String message, Attr[] extra) { + if (!handler.enabled(level, attrs)) { + return; + } + handler.handle(new LogRecord(timestamp=clock.now, + level=level, + message=message, + attrs=attrs + extra)); + } +} +``` + +### The `Attr` type + +This is the unit of structured information. Always typed key/value, never free-form: + +```ecstasy +const Attr(String key, AttrValue value); + +const AttrValue + | StringValue(String value) + | IntValue(Int value) + | FloatValue(Float value) + | BoolValue(Boolean value) + | TimeValue(Time value) + | DurationValue(Duration value) + | ErrorValue(Exception value) + | GroupValue(Attr[] members) // nested object + | LazyValue(function AttrValue ()); // resolved at handler time +``` + +`LazyValue` is the slog `LogValuer` mechanism. The handler calls the function only when +the event is actually being emitted, so disabled levels never resolve the value. + +### The `Handler` interface + +Replaces `LogSink`. Takes a richer record, returns nothing: + +```ecstasy +interface Handler { + Boolean enabled(Level level, Attr[] context); + void handle(LogRecord record); + + /** + * Return a new handler that pre-binds these attributes; child loggers' `.with(...)` + * derivation goes through this so the handler can pre-render context once. + */ + Handler withAttrs(Attr[] extra); + + /** + * Open a structured group — output between matching `WithGroup` boundaries gets + * nested under `name` in the rendered output. + */ + Handler withGroup(String name); +} +``` + +### Acquisition + +`@Inject Logger logger` still works — the binding is the platform-controlled handler. +Derived loggers are explicit: + +```ecstasy +@Inject Logger logger; + +void handleRequest(Request req) { + Logger reqLog = logger.with( + new Attr("requestId", new StringValue(req.id)), + new Attr("user", new StringValue(req.user)) + ); + + reqLog.info("incoming"); + process(req, reqLog); +} + +void process(Request req, Logger log) { + log.info("validating", [new Attr("size", new IntValue(req.size))]); + // ... +} +``` + +There is no `MDC.put`. Context lives on a logger value that gets passed down explicitly. + +### Default handlers + +```ecstasy +service TextHandler implements Handler { /* writes "key=value key=value" lines */ } +service JsonHandler implements Handler { /* one JSON object per line */ } +service TeeHandler implements Handler { /* fans out to many sub-handlers */ } +``` + +`TextHandler` is the analogue of slog's `TextHandler`; `JsonHandler` is `slog.JSONHandler`. + +### Example call sites + +**Free-form log line, no structured fields:** + +```ecstasy +logger.info("startup complete"); +``` + +**Structured event:** + +```ecstasy +logger.info("payment processed", [ + new Attr("paymentId", new StringValue(payment.id)), + new Attr("amount", new IntValue(payment.amount)), + new Attr("currency", new StringValue(payment.currency)) +]); +``` + +**Nested group:** + +```ecstasy +logger.group("network").info("connected", [ + new Attr("host", new StringValue(addr.host)), + new Attr("port", new IntValue(addr.port)) +]); +``` + +Renders as JSON: + +```json +{"level":"INFO","msg":"connected","network":{"host":"db.internal","port":5432}} +``` + +**Lazy attribute:** + +```ecstasy +logger.debug("snapshot", [ + new Attr("dump", new LazyValue(() -> new StringValue(serializer.dump(thing)))) +]); +``` + +`serializer.dump` runs only if `Debug` is enabled. + +**Per-request derived logger (replaces MDC):** + +```ecstasy +@Inject Logger logger; +Logger reqLog = logger + .with(new Attr("requestId", new StringValue(req.id))) + .with(new Attr("tenant", new StringValue(req.tenant))); + +reqLog.info("dispatch"); +process(req, reqLog); +``` + +## What this design wins + +- **Structured logging is the primary path.** No KV pairs glued onto the side of a + free-form message; the message is one of many fields. JSON output is the natural + default. This is what modern observability stacks (Datadog, Loki, Grafana, ELK) want + anyway. +- **No ambient state.** No MDC `put`/`remove` ceremony. No surprise context bleed + between requests because someone forgot a `finally` block. Loggers are values. +- **`with` and `group` are honest about what they do.** A derived logger is a new value; + deriving is cheap (`O(1)` allocation) and obviously local. +- **Lazy values are first-class.** `LazyValue` slots into the same `AttrValue` union as + every other type; no separate Supplier-overload story. +- **Handler-side structure, not encoder-side.** A handler that wants pretty-print JSON + reads the structured `Attr[]` directly. There is no message-text-first / structure- + glued-on bifurcation. + +## What this design loses + +- **No instant familiarity for the SLF4J majority.** The dominant working population of + backend engineers in the world today is "people who write `log.info("...", arg)` in + Java." They will look at this and have to learn a new model. That cost is real. +- **No first-class `Marker`.** slog has no markers. Routing-by-tag is done either via + attributes (`new Attr("category", ...)`) plus a handler that filters on them, or via + multiple handlers. This is fine in principle but requires user code to learn the new + pattern. +- **No `MDC`.** Context propagation is *explicit*: derive a logger and pass it down. In + Ecstasy fibers this is sometimes great (no leakage) and sometimes painful (every + function in the chain needs a `Logger` parameter). Java/SLF4J users hit this and + immediately ask "where's MDC?" because that's the muscle memory. +- **More allocation per call site, on the surface.** Every structured event constructs + an `Attr[]` array literal. The compiler can almost certainly elide most of this, but + it's a different shape than the SLF4J `(message, args, ...)` flat call. +- **Verbosity for simple unstructured events.** `slog.Info("starting up")` is fine but + the moment you want one piece of context you're typing + `[new Attr("port", new IntValue(8080))]`. SLF4J's `info("starting up on {}", port)` is + shorter for the human reader, even if it's worse for machines. +- **Configuration story is less standardized.** SLF4J inherits Logback's + configuration-file ecosystem (XML, programmatic, autoscan, reload). slog has handlers, + and that's it. Anything fancier you build yourself. + +## Two-axis comparison + +| | SLF4J-shape (proposed) | slog-shape (this doc) | +|---|---|---| +| **Familiarity to Java engineers** | Instant | Requires learning a new model | +| **Familiarity to Go engineers** | Slight pivot | Native | +| **Structured-first vs message-first** | Message-first, KV pairs additive | Structured-first | +| **Context propagation** | MDC (ambient) + `Logger.with` (explicit, future) | `Logger.with` only (explicit) | +| **Markers** | First-class | Replaced by attribute-based filtering | +| **Lazy values** | Lambda overloads (Option 1 from `LAZY_LOGGING.md`) | `LazyValue` in the `AttrValue` union | +| **JSON output** | Sink-side encoder (`JsonLineLogSink`) | Native (`JsonHandler`) | +| **API↔impl boundary** | `LogSink` interface | `Handler` interface | +| **Configuration model** | Inherits Logback ecosystem (future) | DIY per-handler | +| **Allocation per disabled call** | Zero (level check, no formatting, no MDC snapshot) | Zero (level check first) | +| **Allocation per enabled simple call** | One `LogEvent` const | One `LogRecord` const + one `Attr[]` | +| **Default sink/handler shipped with v0** | `ConsoleLogSink` (text) | `TextHandler`, `JsonHandler` | + +Both designs land at the same place on performance once the level check short-circuits +disabled events. They diverge sharply on user-facing ergonomics. + +## What we'd be saying yes to, what we'd be saying no to + +**Yes:** + +- A clean slate that maps onto modern observability tools. +- One less concept (no MDC). +- Structured logging without the "but the encoder has to be configured" footnote. + +**No:** + +- Inheriting SLF4J's audience for free. +- Inheriting Logback's configuration ecosystem for free (later). +- Inheriting markers. + +## Why we're not picking this + +The recommendation in `WHY_SLF4J_AND_INJECTION.md` stands. The argument compresses to: +"the audience that matters most is SLF4J users, and SLF4J's design choices are battle- +tested wins, not legacy baggage." But the slog-shape is a coherent, internally +consistent alternative — it is not a strawman. If at some future point the centre of +gravity in backend engineering shifts decisively toward Go-shaped APIs, this document is +the starting point for revisiting the choice. We can also implement an `slog`-style +*adapter* over the SLF4J-shaped core later, exactly the way `slf4j-jul-impl` adapts JUL +to SLF4J — which is a reminder that the choice is recoverable in either direction. + +## Hybrid path, if we ever want it + +The interesting hybrid is **SLF4J-shaped facade, slog-shaped event model.** Concretely: + +- Keep `Logger`, `Marker`, `MDC`, the fluent builder. +- Replace the free-form `arguments: Object[]` on `LogEvent` with a typed + `attrs: Attr[]`. +- Have `MessageFormatter` resolve `{}` placeholders against `attrs` by index, but also + have a `JsonLineLogSink` render `attrs` directly as a JSON object. + +That gives SLF4J users their shape and slog users their wire format. It's also +strictly more complex than either pure design. We do not recommend it for v0; the right +moment to consider it is after the basic SLF4J-shaped library is shipping and we know +which axis we wish we had more of. diff --git a/doc/logging/CUSTOM_SINKS.md b/doc/logging/CUSTOM_SINKS.md new file mode 100644 index 0000000000..7d169a8d57 --- /dev/null +++ b/doc/logging/CUSTOM_SINKS.md @@ -0,0 +1,270 @@ +# Writing a custom `LogSink` + +`LogSink` is the API/impl boundary for `lib_logging`. Anything below it is replaceable +without touching caller code. This document walks through writing a custom sink end to +end. + +## The contract + +```ecstasy +interface LogSink { + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null); + void log(LogEvent event); +} +``` + +That's it. Two methods. Everything else — message substitution, MDC capture, marker +filtering, level check fast paths — happens in the `BasicLogger` *above* the sink. Sinks +just decide: + +1. **`isEnabled`** — should this event even be considered? Called once per log statement + on the hot path. Must be cheap. +2. **`log`** — the event has been built; emit it. The `LogEvent.message` is already + substituted; `mdcSnapshot` is captured. Sinks that want to render structured data + read `event.marker`, `event.exception`, `event.mdcSnapshot`, and (once added) + `event.keyValues`. + +## A worked example: a counting sink + +The simplest non-trivial sink — counts events per level. Useful for metrics export. + +```ecstasy +service CountingLogSink + implements LogSink { + + public/private Map counts = new HashMap(); + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return True; + } + + @Override + void log(LogEvent event) { + counts.process(event.level, e -> { + e.value = e.exists ? e.value + 1 : 1; + return Null; + }); + } +} +``` + +Wiring it up — see `OPEN_QUESTIONS.md` for the runtime side; for now, you can construct +a `BasicLogger` directly: + +```ecstasy +LogSink sink = new CountingLogSink(); +Logger logger = new BasicLogger("my.module", sink); +logger.info("hello"); +logger.warn("uh oh"); +assert sink.counts[Info] == 1; +assert sink.counts[Warn] == 1; +``` + +## A more realistic example: writing to a file + +```ecstasy +service FileLogSink + implements LogSink { + + @Inject FileSystem fs; + + public/private Level rootLevel = Info; + + private File file; + private Writer writer; + + construct(Path path, Level rootLevel = Info) { + this.rootLevel = rootLevel; + this.file = fs.fileFor(path).ensure(); + this.writer = file.appender(); + } + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= rootLevel.severity; + } + + @Override + void log(LogEvent event) { + StringBuffer buf = new StringBuffer(); + buf.append(event.timestamp.toString()) + .append(' ') + .append(event.level.name.padRight(' ', 5)) + .append(' ') + .append(event.loggerName) + .append(": ") + .append(event.message) + .append('\n'); + writer.print(buf.toString()); + if (Exception e ?= event.exception) { + writer.print(e.toString()).print('\n'); + } + } +} +``` + +Notes: +- `fs` is injected — same pattern as `Console`. The sink doesn't pick file paths; the + caller does. +- The constructor opens the file once. Sinks are expected to be services, so the + writer state is service-local and thread-safe. +- The `level.severity >= rootLevel.severity` pattern is the canonical level filter. + +## Per-logger thresholds (logback-style) + +Many sinks want different thresholds for different logger names — the classic +`com.example.foo at DEBUG, root at INFO` pattern. + +```ecstasy +service HierarchicalLogSink + implements LogSink { + + @Inject Console console; + + public/private Map perLogger = new HashMap(); + public/private Level rootLevel = Info; + + void setLevel(String prefix, Level level) { + perLogger.put(prefix, level); + } + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= effectiveLevel(loggerName).severity; + } + + @Override + void log(LogEvent event) { + // ...same as ConsoleLogSink, with thresholds applied. + } + + /** + * Walk the logger name from longest prefix to shortest, returning the first match. + * "com.example.foo.bar" matches "com.example.foo" before "com.example". + */ + private Level effectiveLevel(String loggerName) { + String name = loggerName; + while (True) { + if (Level lvl := perLogger.get(name)) { + return lvl; + } + Int dot = name.lastIndexOf('.'); + if (dot <= 0) { + return rootLevel; + } + name = name[0 ..< dot]; + } + } +} +``` + +This is roughly what Logback's `LoggerContext` does internally. Wrapping it as a sink +keeps the rest of the library unaware. + +## A composite (tee) sink + +```ecstasy +service TeeLogSink(LogSink[] sinks) + implements LogSink { + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + for (LogSink sink : sinks) { + if (sink.isEnabled(loggerName, level, marker)) { + return True; + } + } + return False; + } + + @Override + void log(LogEvent event) { + for (LogSink sink : sinks) { + if (sink.isEnabled(event.loggerName, event.level, event.marker)) { + sink.log(event); + } + } + } +} +``` + +`TeeLogSink([new ConsoleLogSink(), new FileLogSink(/var/log/app.log)])` is the moral +equivalent of attaching multiple Logback appenders to one logger. + +## Filtering by marker + +Markers exist precisely so that sinks can decide based on category, not on message text. + +```ecstasy +service MarkerFilteringLogSink(LogSink delegate, Marker required) + implements LogSink { + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return marker?.contains(required) : False; + } + + @Override + void log(LogEvent event) { + if (Marker m ?= event.marker, m.contains(required)) { + delegate.log(event); + } + } +} +``` + +Pair this with a `TeeLogSink` to e.g. send everything to the console but only +`AUDIT`-marked events to a separate audit file. + +## Structured / JSON output + +```ecstasy +service JsonLineLogSink + implements LogSink { + + @Inject Console console; + public/private Level rootLevel = Info; + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= rootLevel.severity; + } + + @Override + void log(LogEvent event) { + // See STRUCTURED_LOGGING.md for the full sketch — emits one JSON object per line. + } +} +``` + +## Things sinks should not do + +- **Don't throw.** A sink that throws breaks the program. If your network endpoint is + down, drop the event or fall back to stderr. Exceptions in `log()` are not the + caller's problem. +- **Don't block on the hot path.** If the sink is doing something slow (network, + encryption, disk), wrap it in an `AsyncLogSink` queue (future) rather than serializing + every log call's caller behind it. +- **Don't snapshot MDC yourself.** The `BasicLogger` already does it before the event + reaches you. `event.mdcSnapshot` is the immutable map you should render. +- **Don't re-format the message.** It has already been through `MessageFormatter` once + by the time it reaches the sink. Render `event.message` verbatim. + +## Things the runtime is expected to do for you + +- **Level check fast path.** `BasicLogger.emit` calls `sink.isEnabled` *before* + formatting the message, so a disabled-level call costs you exactly one method call + with cheap arithmetic. +- **MDC snapshot.** Captured in `BasicLogger`, stored in `event.mdcSnapshot`. +- **Throwable promotion.** `MessageFormatter.format` enforces SLF4J's "trailing + Throwable becomes the cause" rule before the event reaches you. + +## Quick checklist for a production-quality sink + +- [ ] `isEnabled` is `O(1)` or close to it — no I/O. +- [ ] `log` does not throw under any internal-failure mode. +- [ ] The threshold is configurable at runtime. +- [ ] Output is line-oriented or otherwise framed so `tail -f` is meaningful. +- [ ] The exception is rendered fully — message, stack frames, causes. +- [ ] If asynchronous, a flush mechanism exists for clean shutdown. diff --git a/doc/logging/DESIGN.md b/doc/logging/DESIGN.md new file mode 100644 index 0000000000..279af2d6cc --- /dev/null +++ b/doc/logging/DESIGN.md @@ -0,0 +1,153 @@ +# lib_logging — Design + +## High-level architecture + +``` + ┌──────────────────────────────────┐ + user code → │ Logger (interface) │ ← public API + │ Level / Marker / MDC / LogEvent │ + │ LoggingEventBuilder │ + │ LoggerFactory │ + ├──────────────────────────────────┤ + │ BasicLogger / BasicEventBuilder │ ← canonical impl of the API + │ MessageFormatter │ + ├──────────────────────────────────┤ + │ LogSink (interface) │ ← API ↔ impl boundary + ├──────────────────────────────────┤ + │ ConsoleLogSink │ ← shipped impls + │ NoopLogSink │ + │ MemoryLogSink │ + │ (future) LogbackLogSink │ + │ (future) NativeSlf4jLogSink │ + └──────────────────────────────────┘ +``` + +User code holds a `Logger`. The `Logger` is a `BasicLogger` that holds a `LogSink`. The +sink is whatever the runtime injected. Everything above the `LogSink` line is sink-agnostic +and stable; everything below is swappable. + +This is the same architecture the SLF4J full guide describes (`slf4j_full_guide.md` at +the repo root, section "Architecture") — it is a deliberate decision to mirror it because +SLF4J got the layering right, and copying it gets us instant familiarity for free. + +## Why named injection + +Ecstasy already routes `Console` through `@Inject Console console;` — see +`lib_ecstasy/src/main/x/ecstasy/io/Console.x` and `javatools_jitbridge/.../TerminalConsole.java`. +The runtime controls which `Console` impl gets wired up. Loggers fit the same shape: +the runtime decides where output goes (in v0, always `ConsoleLogSink`; later, possibly a +configurable `LogbackLogSink`), and user code is sink-agnostic. + +The injection key is `(TypeConstant, String name)`; for `@Inject Logger`, the empty/default +name resolves to the global default logger. For `@Inject("com.example") Logger`, the name +becomes the logger's name. See `OPEN_QUESTIONS.md` for the wildcard-name resolution +change required in `nMainInjector.supplierOf` — the existing `Resource` map is exact-match. + +## API surface + +### `Logger` + +The user-facing facade. Five level methods (`trace`, `debug`, `info`, `warn`, `error`), +each accepting `(message, arguments, cause, marker)` with sensible defaults. Five level +checks (`infoEnabled`, etc.). A polymorphic `log(level, ...)`. A fluent `atInfo()` / +`atLevel(...)` family that returns a `LoggingEventBuilder`. + +The signatures intentionally collapse SLF4J's many overloads into one canonical signature +per level using Ecstasy's default-argument support. The fluent builder covers the cases +where the parameter list isn't known statically. + +### `Level` + +`Trace, Debug, Info, Warn, Error, Off`. `Off` is sink-side only; never emitted. +`severity` field for cheap threshold comparisons. + +### `Marker` / `MarkerFactory` / `BasicMarker` + +`MarkerFactory.getMarker(name)` returns interned markers; `getDetachedMarker(name)` +returns fresh ones. Markers can have child references (DAG), and `contains` does the +transitive check. The default `BasicMarker` is straight from the SLF4J playbook — +intentionally minimal, the rich filtering is the sink's job. + +### `MDC` + +Service exposing `put / get / remove / clear / copyOfContextMap`. Sinks that care about +MDC capture `copyOfContextMap` when emitting; the snapshot is included in `LogEvent`. + +### `LogEvent` + +Immutable `const` record carrying loggerName, level, message (already substituted), +marker, exception, arguments, mdc snapshot, thread/fiber name, timestamp. + +### `LogSink` + +Two methods: `isEnabled(loggerName, level, marker)` and `log(event)`. This is the +boundary; everything below is replaceable. + +### `LoggingEventBuilder` + +SLF4J 2.x fluent builder: `setMessage / addArgument / addMarker / setCause / +addKeyValue / log`. The `at*()` methods on `Logger` short-circuit to a no-op builder +when the level is disabled, so callers don't pay for argument construction that won't +be used. + +## Default implementations + +### `BasicLogger` + +Holds `(name, sink)`. Each emission method: +1. Calls `sink.isEnabled(...)`. If False, return immediately. +2. Calls `MessageFormatter.format(message, arguments)` to substitute placeholders and + detect the SLF4J throwable-promotion case. +3. Snapshots MDC. +4. Constructs a `LogEvent` and hands it to the sink. + +### `ConsoleLogSink` + +Service holding `@Inject Console console;` and a `rootLevel` threshold (default `Info`). +Emits one line per event in a fixed format: timestamp, thread, padded level, logger name, +message, optional `[marker=NAME]` suffix; appends the exception on a following line. + +### `NoopLogSink` + +Drops everything. `isEnabled` returns False so callers that respect the check skip all +formatting work. Useful for libraries that want quiet defaults. + +### `MemoryLogSink` + +Captures events in an array. Test-only; not registered as a default. + +## Future: `lib_logging_logback` + +A separate module providing a configuration-driven sink. Reads a config tree +(programmatic, possibly XML or JSON) describing: + +- per-logger thresholds (`com.example.foo` at `Debug`, root at `Info`); +- appenders (Console, File, Rolling, Network, JSON); +- layouts (PatternLayout, JSONLayout); +- filters (level, marker, MDC, regex on message); +- async wrappers. + +The whole thing is just a different `LogSink` implementation. User code that already +worked against `lib_logging` keeps working. Detailed sketch in `LOGBACK_INTEGRATION.md`. + +## Native bridge — could we wrap real Logback? + +Yes, technically. `javatools_jitbridge` already imports `jline` (a third-party Java +library) for the terminal console, proving the bridge can carry external dependencies. +A `RTLogbackSink.java` extending `nService` and registered in +`nMainInjector.addNativeResources()` could wrap `org.slf4j.Logger` directly. SLF4J and +Logback are already in the version catalog (`lang-slf4j`, `lang-logback`), used by the +lang tooling. + +We don't recommend this as the primary path — see `NATIVE_BRIDGE.md` for the full +analysis — but it's a feasible escape hatch and worth documenting because it constrains +the design. + +## What isn't here yet + +- The runtime-side injection wiring. The `BasicLogger` and the sink classes exist as + Ecstasy code, but `@Inject Logger` doesn't actually resolve to one until + `RTLogger.java` is added in `javatools_jitbridge` and registered in `nMainInjector`. +- The real `MessageFormatter`. Currently a stub that returns the message unchanged. +- Tests. None yet; the `manualTests/` sample is the next step. +- Compiler-side default name from module — open question. diff --git a/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md b/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md new file mode 100644 index 0000000000..91637111b8 --- /dev/null +++ b/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md @@ -0,0 +1,346 @@ +# Ecstasy `lib_logging` vs Java SLF4J — side by side + +For every API in `lib_logging` this document shows a working SLF4J 2.x example in Java +first, then the equivalent in Ecstasy. The point is to make adoption frictionless: anyone +who has used SLF4J should be able to skim once and be productive. + +## 1. Hello world + +**Java (SLF4J):** + +```java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Demo { + private static final Logger log = LoggerFactory.getLogger(Demo.class); + + public static void main(String[] args) { + log.info("Hello {}", "world"); + } +} +``` + +**Ecstasy (`lib_logging`):** + +```ecstasy +module Demo { + package log import logging.xtclang.org; + + void run() { + @Inject log.Logger logger; + logger.info("Hello {}", ["world"]); + } +} +``` + +The SLF4J static-factory pattern still works in Ecstasy: + +```ecstasy +@Inject log.LoggerFactory factory; +log.Logger logger = factory.getLogger(Demo); +``` + +## 2. Level checks (avoid expensive arg construction) + +**Java:** + +```java +if (log.isDebugEnabled()) { + log.debug("computed result: {}", expensiveSerialize(thing)); +} +``` + +**Ecstasy:** + +```ecstasy +if (logger.debugEnabled) { + logger.debug("computed result: {}", [expensiveSerialize(thing)]); +} +``` + +## 3. Logging an exception + +**Java:** + +```java +try { + process(req); +} catch (Exception e) { + log.error("processing failed for {}", req.id(), e); +} +``` + +**Ecstasy:** + +```ecstasy +try { + process(req); +} catch (Exception e) { + logger.error("processing failed for {}", [req.id], cause=e); +} +``` + +Both follow the SLF4J **throwable-promotion rule**: a trailing `Exception`/`Throwable` +that has no matching placeholder is treated as the cause, not as a substitution. + +## 4. Markers + +**Java:** + +```java +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +private static final Marker AUDIT = MarkerFactory.getMarker("AUDIT"); +log.info(AUDIT, "user {} signed in", userId); +``` + +**Ecstasy:** + +```ecstasy +@Inject log.MarkerFactory markers; +log.Marker AUDIT = markers.getMarker("AUDIT"); +logger.info("user {} signed in", [userId], marker=AUDIT); +``` + +## 5. MDC + +**Java:** + +```java +import org.slf4j.MDC; + +MDC.put("requestId", req.id()); +try { + log.info("handling request"); + handle(req); +} finally { + MDC.remove("requestId"); +} +``` + +**Ecstasy:** + +```ecstasy +@Inject log.MDC mdc; + +mdc.put("requestId", req.id); +try { + logger.info("handling request"); + handle(req); +} finally { + mdc.remove("requestId"); +} +``` + +## 6. Fluent event builder (SLF4J 2.x) + +**Java:** + +```java +log.atInfo() + .addMarker(AUDIT) + .addKeyValue("requestId", req.id()) + .addKeyValue("user", userId) + .setCause(e) + .log("payment {} failed for {}", paymentId, customer); +``` + +**Ecstasy:** + +```ecstasy +logger.atInfo() + .addMarker(AUDIT) + .addKeyValue("requestId", req.id) + .addKeyValue("user", userId) + .setCause(e) + .log("payment {} failed for {}", paymentId, customer); +``` + +When the level is disabled, both implementations short-circuit to a no-op builder so the +`addArgument` / `addKeyValue` calls are free. + +## 7. Acquiring a logger by name vs by class + +**Java:** + +```java +Logger byName = LoggerFactory.getLogger("com.example.foo"); +Logger byClass = LoggerFactory.getLogger(MyClass.class); +``` + +**Ecstasy:** + +```ecstasy +@Inject log.LoggerFactory factory; +log.Logger byName = factory.getLogger("com.example.foo"); +log.Logger byClass = factory.getLogger(MyClass); + +// Or, by injection: +@Inject("com.example.foo") log.Logger logger; +``` + +## 8. Configuration: changing the root level + +**Java (`logback.xml`):** + +```xml + + + + + +``` + +**Java (programmatic, Logback):** + +```java +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import org.slf4j.LoggerFactory; + +((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(Level.DEBUG); +``` + +**Ecstasy (default `ConsoleLogSink`):** + +```ecstasy +@Inject log.ConsoleLogSink sink; +sink.setRootLevel(log.Debug); +``` + +For a richer per-logger configuration tree see `LOGBACK_INTEGRATION.md` — the future +`lib_logging_logback` module would expose a programmatic and/or file-based config API +analogous to Logback's `JoranConfigurator`. + +## 9. Custom appender / sink + +**Java (Logback):** + +```java +public class CountingAppender extends AppenderBase { + private final AtomicLong count = new AtomicLong(); + + @Override + protected void append(ILoggingEvent e) { + count.incrementAndGet(); + } + + public long getCount() { return count.get(); } +} +``` + +Wire it via `logback.xml`: + +```xml + + +``` + +**Ecstasy (`lib_logging`):** + +```ecstasy +service CountingSink + implements log.LogSink { + + public/private Int count = 0; + + @Override + Boolean isEnabled(String name, log.Level level, log.Marker? marker = Null) = True; + + @Override + void log(log.LogEvent event) { + ++count; + } +} +``` + +Wire it by replacing the injected default sink — see `CUSTOM_SINKS.md` for the runtime +side of the story. + +## 10. NOP / silent logger (for libraries that opt out) + +**Java:** + +```java +// add slf4j-nop to the classpath, OR: +Logger silent = NOPLogger.NOP_LOGGER; +``` + +**Ecstasy:** + +```ecstasy +log.LogSink silent = new log.NoopLogSink(); +log.Logger logger = new log.BasicLogger("com.example", silent); +``` + +## 11. Capturing events in a test + +**Java (Logback `ListAppender`):** + +```java +ListAppender capture = new ListAppender<>(); +capture.start(); +((Logger) LoggerFactory.getLogger("com.example")).addAppender(capture); + +systemUnderTest.run(); + +assertThat(capture.list) + .hasSize(1) + .extracting(ILoggingEvent::getFormattedMessage) + .containsExactly("processed 42 records"); +``` + +**Ecstasy (`MemoryLogSink`):** + +```ecstasy +log.MemoryLogSink capture = new log.MemoryLogSink(); +log.Logger logger = new log.BasicLogger("com.example", capture); + +systemUnderTest.run(logger); + +assert capture.events.size == 1; +assert capture.events[0].message == "processed 42 records"; +``` + +## 12. Throwable promotion + +**Java:** + +```java +log.warn("cleanup failed", new IOException("disk full")); +// SLF4J knows the Throwable is the cause, not a {} substitution. +``` + +**Ecstasy:** + +```ecstasy +logger.warn("cleanup failed", cause=new IOException("disk full")); +``` + +The `MessageFormatter.format` method also enforces the same rule: if the last entry of +`arguments` is an `Exception` and there is no matching placeholder, it is promoted to +the cause. + +## 13. Parameterized message edge cases + +**Java:** + +```java +log.info("{} of {}", current, total); // both filled +log.info("plain message", arg); // arg ignored — no {} +log.info("{} {} {}", "a", "b"); // last {} stays literal +log.info("escaped \\{}", "x"); // emits "escaped {}" +``` + +**Ecstasy:** + +```ecstasy +logger.info("{} of {}", [current, total]); +logger.info("plain message", [arg]); +logger.info("{} {} {}", ["a", "b"]); +logger.info("escaped \\{}", ["x"]); +``` + +The semantics are the same — `MessageFormatter` is a port of SLF4J's reference behaviour. diff --git a/doc/logging/INJECTED_LOGGER_EXAMPLE.md b/doc/logging/INJECTED_LOGGER_EXAMPLE.md new file mode 100644 index 0000000000..c6f963d0f4 --- /dev/null +++ b/doc/logging/INJECTED_LOGGER_EXAMPLE.md @@ -0,0 +1,204 @@ +# `@Inject Logger` end-to-end — a working example + +This document shows the canonical way an Ecstasy application uses `lib_logging`. The +goal is to be the file someone reads when they ask "okay, but what does it actually +look like in real code?" + +The accompanying executable sample lives at +[`manualTests/src/main/x/TestLogger.x`](../../manualTests/src/main/x/TestLogger.x). + +> **Status reminder.** The runtime injection plumbing (the native side that resolves +> `@Inject Logger logger;` to a real `BasicLogger` instance) is not yet wired up — see +> `OPEN_QUESTIONS.md`. The example below is the shape user code is *meant* to take. +> Until the runtime is wired, equivalent behaviour can be obtained by constructing +> `BasicLogger` directly against any `LogSink`, exactly as the unit tests under +> `lib_logging/src/test/x/LoggingTest/` do. + +## The smallest possible app + +```ecstasy +module HelloLogging { + package log import logging.xtclang.org; + + void run() { + @Inject log.Logger logger; + logger.info("hello, {}", ["world"]); + } +} +``` + +That's it. There is no factory call, no static initializer, no configuration file. The +runtime container hands the application a `Logger`; the default sink (`ConsoleLogSink`) +emits the line to the platform `Console`. Output: + +``` +2026-04-29T11:23:45.012Z [main] INFO HelloLogging: hello, world +``` + +## A more realistic app + +The example below is closer to what real code looks like. It demonstrates: +- a *named* logger acquired by injection; +- per-level emission with parameterized messages; +- attaching an exception via the `cause` parameter; +- using a marker for categorical routing; +- using MDC for request-scoped context; +- the SLF4J 2.x fluent event builder. + +```ecstasy +module PaymentService { + package log import logging.xtclang.org; + + @Inject("PaymentService") log.Logger logger; + @Inject log.MarkerFactory markers; + @Inject log.MDC mdc; + + log.Marker AUDIT = markers.getMarker("AUDIT"); + + void run() { + processPayment("p_123", 4200, "EUR", "user_42"); + } + + void processPayment(String paymentId, Int amount, String currency, String userId) { + // Per-request context that should appear on every event below. + mdc.put("paymentId", paymentId); + mdc.put("user", userId); + + try { + logger.info("processing payment of {} {}", [amount, currency]); + + validate(paymentId, amount); + charge(paymentId, amount, currency); + + // Structured + categorical: tag with AUDIT, attach typed KV pairs. + logger.atInfo() + .addMarker(AUDIT) + .addKeyValue("amount", amount) + .addKeyValue("currency", currency) + .log("payment completed"); + + } catch (Exception e) { + logger.error("payment failed: {}", [paymentId], cause=e); + } finally { + mdc.remove("paymentId"); + mdc.remove("user"); + } + } + + void validate(String paymentId, Int amount) { + if (amount <= 0) { + throw new IllegalArgumentException($"amount must be positive: {amount}"); + } + if (logger.debugEnabled) { + logger.debug("validated {} (amount={})", [paymentId, amount]); + } + } + + void charge(String paymentId, Int amount, String currency) { + // ... (calls into payment-processor service) + } +} +``` + +When run with the default `ConsoleLogSink` and root level `Info`, this prints (for a +successful payment): + +``` +2026-04-29T11:23:45.012Z [main] INFO PaymentService: processing payment of 4200 EUR +2026-04-29T11:23:45.034Z [main] INFO PaymentService: payment completed [marker=AUDIT] +``` + +The `validate` call's `debug` line is elided because `Debug < Info`, and crucially the +`logger.debugEnabled` guard keeps the parameter array `[paymentId, amount]` from being +constructed at all. + +## Scaling up — multiple loggers, one per module/class + +The SLF4J convention is one logger per class, named by the class. In Ecstasy the +equivalent is one named injection per logical scope: + +```ecstasy +module BillingService { + package log import logging.xtclang.org; + + service Invoicer { + @Inject("billing.Invoicer") log.Logger logger; + + void issue(Invoice inv) { + logger.info("issuing invoice {}", [inv.id]); + // ... + } + } + + service Charger { + @Inject("billing.Charger") log.Logger logger; + + void charge(Invoice inv) { + logger.info("charging {} for {}", [inv.customer, inv.total]); + // ... + } + } +} +``` + +Two loggers, two names. A configuration-driven sink (`lib_logging_logback`, future) can +set `billing.Charger` to `Debug` while keeping `billing.Invoicer` at `Info`. + +## Without injection — `LoggerFactory` + +For static initialisers or anywhere injection isn't in scope: + +```ecstasy +module Util { + package log import logging.xtclang.org; + + static log.Logger logger = log.LoggerFactory.getLogger("util.Helper"); + + static void greet(String name) { + logger.info("hello, {}", [name]); + } +} +``` + +`LoggerFactory.getLogger` is itself a service that consults an injected default +`LogSink`, so the per-container override property still holds. + +## Without the runtime wiring (today's reality) + +Until the runtime side resolves `@Inject Logger`, you can do the same thing by hand: + +```ecstasy +module Today { + package log import logging.xtclang.org; + + void run() { + @Inject Console console; + log.LogSink sink = new log.ConsoleLogSink(); + log.Logger logger = new log.BasicLogger("Today", sink); + + logger.info("hello, {}", ["world"]); + } +} +``` + +This is exactly what the unit tests in `lib_logging/src/test/x/LoggingTest/` do, and +exactly what the manualTest does today. Once `RTLogger.java` lands in +`javatools_jitbridge` and is registered in `nMainInjector`, the explicit construction +becomes unnecessary and the code in the earlier sections is what callers will actually +write. + +## Cheat sheet + +| You want to… | Write | +|---|---| +| Log info | `logger.info("msg")` | +| Log info with args | `logger.info("msg {}", [arg])` | +| Log info with exception | `logger.info("msg", cause=e)` | +| Log info with marker | `logger.info("msg", marker=AUDIT)` | +| Cheap level check | `if (logger.infoEnabled) { ... }` | +| Fluent builder | `logger.atInfo().addMarker(AUDIT).log("msg")` | +| Get a logger by name | `@Inject("foo") Logger logger;` | +| Get a logger by class | `LoggerFactory.getLogger(MyClass)` | +| Attach context for this request | `mdc.put("requestId", id);` | + +That covers ~95% of real-world logging use. diff --git a/doc/logging/LAZY_LOGGING.md b/doc/logging/LAZY_LOGGING.md new file mode 100644 index 0000000000..371d271a38 --- /dev/null +++ b/doc/logging/LAZY_LOGGING.md @@ -0,0 +1,243 @@ +# Lazy logging exploration + +This document explores how to give `lib_logging` users the same ergonomic property the +Kotlin `kotlin-logging` community loves: + +```kotlin +log.info { "expensive: ${serializer.dump(thing)}" } +``` + +The lambda body is evaluated *only* if the level is enabled. Caller code is one line and +zero-cost when the level is off. + +## Why this matters + +Three things together motivate caring about lazy logging: + +1. **Argument construction is the dominant cost of a disabled log call.** SLF4J's `{}` + substitution defers the *formatting* but not the *argument materialization* — if you + write `logger.debug("size: {}", expensiveSerialize(thing))`, the + `expensiveSerialize(thing)` call still runs even when DEBUG is off. The level check + only protects the `MessageFormatter` work, not the caller's expression evaluation. +2. **The traditional escape hatch is verbose.** `if (logger.debugEnabled) { logger.debug(...) }` + works, but doubles the line count of every expensive log site, and people skip the + guard until a profiler tells them to add it back. +3. **Modern logging libraries have figured this out.** Kotlin, JUL, SLF4J 2.x, Scala, + Rust, C++ spdlog — they all offer some lazy-by-default emission. We should too. + +## What we already have for free + +`lib_logging` already eliminates two of the three sources of disabled-call overhead: + +- **Deferred formatting.** `MessageFormatter` only runs after the `sink.isEnabled` check. + When the level is off, no string is built. +- **Builder short-circuits.** `logger.atDebug()` will, when fully implemented, return a + no-op builder when DEBUG is disabled, so the chain + `logger.atDebug().addArgument(x).addKeyValue("k", v).log("...")` does no work. + +The remaining gap is **caller-side expression evaluation** of `arguments`/values. That's +exactly the gap the Kotlin lambda form closes. + +## Industry survey — how other languages address this + +| Ecosystem | Mechanism | Notes | +|---|---|---| +| **Java SLF4J 2.x** | `LoggingEventBuilder.log(Supplier)` and `addArgument(Supplier)` | The builder evaluates suppliers lazily. Older `Logger` overloads do not. | +| **Java JUL** | `Logger.log(Level, Supplier)` since Java 8 | Lazy variant added late but in stdlib. | +| **kotlin-logging** | Inline functions taking `() -> Any?` lambda, e.g. `logger.info { msg }` | The de-facto Kotlin idiom. Inlined at the call site so no allocation. | +| **Scala scala-logging** | Compile-time macros — the call expands at compile time to wrap the message in a `if (logger.isDebugEnabled)` guard | Zero overhead when disabled, no runtime check beyond the level read. | +| **Rust `log`** | Macros (`debug!`, `info!`) | Same idea: macro expands to the if-enabled guard around format args. Compile-time, not runtime. | +| **Go `log/slog`** | `slog.LogValuer` interface lets the value type defer its own resolution | Plus handler-side `Enabled(level)` check before any attribute is materialized. | +| **C++ spdlog / glog** | Macros (`SPDLOG_DEBUG`, `LOG(INFO)`) — expand to a guarded statement | Pre-processor lazy. | +| **Python stdlib `logging`** | `%s` placeholder + `*args` gets deferred formatting; for full arg laziness people guard on `isEnabledFor(...)` | No first-class lambda support; the convention is good enough for most cases. | +| **Erlang/Elixir Logger** | Reports as a *function* `Logger.debug(fn -> "..." end)` | Lambda-based laziness baked into the API. | + +The pattern is universal: every modern logging library has a way to defer message +construction. Some use compile-time macros, some use runtime closures, some use type- +based deferral. **The user expectation is that this works.** Anyone porting a Kotlin or +SLF4J 2.x service over will look for it first thing. + +## Three options for `lib_logging` + +We have three plausible paths. They are not mutually exclusive — we can ship one now and +add others later. Listed in order of ease. + +### Option 1 — `Supplier` overloads on `Logger` + +Add lambda-taking variants of every level method. The signature uses Ecstasy's +function-typed parameters: + +```ecstasy +interface Logger { + // Existing: + void info(String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + // Proposed: lambda variant. + void info(function String () messageFn, + Exception? cause = Null, + Marker? marker = Null); + + // Same for trace/debug/warn/error and the polymorphic `log`. +} +``` + +Caller side: + +```ecstasy +logger.info(() -> $"expensive: {serializer.dump(thing)}"); +``` + +Implementation in `BasicLogger`: + +```ecstasy +void info(function String () messageFn, + Exception? cause = Null, + Marker? marker = Null) { + if (!sink.isEnabled(name, Info, marker)) { + return; + } + String msg = messageFn(); + sink.log(new LogEvent(name, Info, msg, ...)); +} +``` + +**Pros** + +- Zero language work — implementable today against the existing `lib_logging` stub. +- One-line change at every call site that wants laziness. +- Plays nicely with markers, MDC, and the fluent builder. +- Works the same way `slf4j-api`'s newer fluent `log(Supplier)` works, so the + mental model carries over. + +**Cons** + +- The closure allocates (one captured-frame object per call site, even when the level is + enabled). For hot paths in a long-running service this is non-trivial. Mitigation: + Ecstasy's compiler can in theory inline a closure that's used exactly once and + doesn't escape; whether it does today is a separable optimisation question. +- Slightly less ergonomic than the Kotlin lambda form, which uses `{ ... }` braces with + no arrow. We can revisit syntax if Ecstasy gains a Kotlin-style trailing-block sugar. + +### Option 2 — Build on top of issue #437 scope functions + +Issue [xtclang/xvm#437](https://github.com/xtclang/xvm/issues/437) proposes adding +Kotlin-style scope functions to Ecstasy. The proposed shape is a postfix `.{ ... }` +block where `this` is the receiver: + +```ecstasy +val adam = new Person("Adam").{ + age = 32; + city = "London"; +}; +``` + +This wouldn't directly give us the Kotlin `logger.info { msg }` form, but it would make +the level-guarded form *short enough to use everywhere*: + +```ecstasy +// Without #437: +if (logger.infoEnabled) { + logger.info($"expensive: {serializer.dump(thing)}"); +} + +// With #437 (logger.{ ... } opens a scope where `this` is `logger`): +logger.{ + if (infoEnabled) { + info($"expensive: {serializer.dump(thing)}"); + } +}; +``` + +That's still not great. Where #437 *would* shine is if it's combined with a small +extension method on `Logger`: + +```ecstasy +class Logger { + // pseudo-extension; the actual mechanism depends on Ecstasy's extension story + void infoIfEnabled(function String () messageFn) { + if (infoEnabled) { + info(messageFn()); + } + } +} + +// usage: +logger.infoIfEnabled(() -> $"expensive: {serializer.dump(thing)}"); +``` + +…which is just Option 1 with a different name. So #437 isn't a substitute for Option 1; +it's a useful complement when the caller wants to do _multiple_ things with the logger +in a level-guarded scope. + +### Option 3 — Compile-time elision via macros / annotation processor + +The Scala / Rust / C++ approach. The compiler sees `logger.debug("...", $arg)` and +expands it to a guarded form. In Ecstasy this would mean an XTC compiler enhancement +that recognises `Logger` calls and rewrites them. + +**Pros** + +- Zero overhead when disabled, even for the closure allocation. +- Caller code looks identical to the eager form. + +**Cons** + +- Compiler-side work; out of scope for the `lib_logging` v0. +- Couples the compiler to a specific library's identity, which is a smell. +- Mostly redundant with Option 1 once Ecstasy's optimizer handles single-use closures. + +We do not recommend this path. + +## Recommendation + +**Ship Option 1 in v0.1.** It's small, it's idiomatic, it lands today, and it makes the +library usable for hot-path code without ceremony. The signature lives in `Logger.x` +and the implementation in `BasicLogger.x`; both already have the surrounding shape. + +**Track #437 as it lands.** When scope functions arrive, document the `logger.{ ... }` +idiom for the cases where multiple log calls share a level guard. No code change needed +in `lib_logging`. + +**Keep Option 3 in mind only if profiling later shows closure allocation is a real +problem.** The right time to invest in compiler-level elision is after we have data +saying it matters. + +## Concrete next step + +Add the lambda overloads to `Logger.x` and `BasicLogger.x`. Update `ECSTASY_VS_JAVA_EXAMPLES.md` +with a side-by-side showing: + +```kotlin +// Kotlin +logger.info { "size: ${expensiveSerialize(thing)}" } +``` + +```ecstasy +// Ecstasy +logger.info(() -> $"size: {expensiveSerialize(thing)}"); +``` + +That's the pull-quote that closes the gap for an SLF4J/Kotlin user evaluating the +library. + +## Wider survey takeaways + +A few patterns recur across the industry survey worth noting because they shape future +decisions: + +1. **The level check is *always* the first thing the implementation does.** Whether the + API is lambda-based, macro-based, or builder-based, every modern library short- + circuits at the level check before any other work. `lib_logging` already does this. +2. **The structured-data story (KV pairs) and the laziness story converge.** SLF4J 2.x's + `addArgument(Supplier)` and slog's `LogValuer` are both "evaluate the value lazily + per attribute". `lib_logging` could follow with `addArgument(function Object ())` on + the builder. Worth doing in the same v0.1 cut as the per-method lambda overloads. +3. **Macros are losing.** Of the languages that started with macro-based logging (C++, + Rust), the Rust crate has been creeping toward closure-based extensibility for years. + Macro-based laziness is a 1990s/early-2000s solution. Closure-based is what's left + standing in the languages that have first-class lambdas. + +This is consistent with picking Option 1 and only revisiting if data demands it. diff --git a/doc/logging/LOGBACK_INTEGRATION.md b/doc/logging/LOGBACK_INTEGRATION.md new file mode 100644 index 0000000000..78424beea8 --- /dev/null +++ b/doc/logging/LOGBACK_INTEGRATION.md @@ -0,0 +1,288 @@ +# Logback-style integration + +`lib_logging` v0 ships a single, intentionally minimal default sink: `ConsoleLogSink`. +For real-world deployment we expect a Logback-equivalent backend — configuration-driven, +multi-appender, hierarchical — to live in a separate module. This document sketches how +that would be shaped. + +It is intentionally *forward-looking*. Nothing here is implemented yet; the point is to +make sure today's API choices don't preclude tomorrow's backend. + +## What "Logback-style" means + +For SLF4J users, "Logback" means a specific bundle of features beyond the SLF4J facade: + +| Feature | Description | +|---|---| +| **Per-logger thresholds** | `com.example.foo` at `DEBUG`, root at `INFO`. The threshold lookup walks the logger-name hierarchy. | +| **Multi-appender** | The same event can fan out to console, file, network, syslog, etc. | +| **Layouts** | Pluggable output formatters: `PatternLayout`, `JSONLayout`, `LogstashEncoder`, etc. | +| **Filters** | Per-appender pre-emit decisions based on level, marker, MDC, regex, etc. | +| **Configuration files** | `logback.xml` (or programmatic). Reload on change. | +| **Async appenders** | A queue + worker thread so producer threads aren't blocked by slow I/O. | +| **Context-aware MDC rendering** | MDC values appear in formatted output without the message having to mention them. | +| **Rolling file appenders** | Time-based, size-based, or composite rotation. | + +The goal: a future `lib_logging_logback` module ships every one of those. + +## The architectural fit + +`lib_logging` already separates: + +``` +Logger ──→ LogSink ──→ (whatever) +``` + +A logback-style backend slots into the `LogSink` slot. From the caller's perspective +nothing changes — `@Inject Logger logger;` still produces a `Logger`, the methods are +the same, the events are the same. The backend just happens to be much smarter about +what it does with them. + +Conceptually the backend itself decomposes into a smaller hierarchy: + +``` +ConfiguredLogSink (the LogSink the runtime injects) + │ + ├── LoggerContext (the per-logger threshold tree, "INFO at root, DEBUG at com.example.foo") + │ + └── Appender[] (each appender = its own "where does the event go" target) + │ + ├── Filter[] (pre-emit predicates: level, marker, MDC, regex) + │ + ├── Layout (LogEvent → bytes, e.g. PatternLayout, JSONLayout) + │ + └── Output (Console, File, RollingFile, Network, ...) +``` + +This isn't novel — it's exactly Logback's mental model. The advantage of writing it as +an Ecstasy module rather than a wrapper around the JVM Logback library is that it gets +to use Ecstasy's own primitives (services for thread-safety, fibers for async, the file +abstraction from `lib_ecstasy`) instead of needing the bridge story discussed in +`NATIVE_BRIDGE.md`. + +## Sketch — the public API of `lib_logging_logback` + +```ecstasy +module logback.xtclang.org { + package log import logging.xtclang.org; + + /** + * The configurable sink that user code wires up via `@Inject log.LogSink`. + */ + service ConfiguredLogSink + implements log.LogSink { + // populated by Configurator + } + + /** + * Programmatic configuration entry point. Equivalent to Logback's JoranConfigurator. + */ + service Configurator { + Configurator setRootLevel(log.Level level); + Configurator setLevel(String loggerPrefix, log.Level level); + Configurator addAppender(Appender appender); + Configurator clear(); + } + + interface Appender { + @RO String name; + Boolean isEnabled(log.LogEvent event); + void append(log.LogEvent event); + } + + interface Layout { + String render(log.LogEvent event); + } + + interface Filter { + Decision decide(log.LogEvent event); + enum Decision { Accept, Neutral, Deny } + } + + // shipped appenders + service ConsoleAppender (String name, Layout layout, Filter[] filters = []) implements Appender; + service FileAppender (String name, Path file, Layout layout, Filter[] filters = []) implements Appender; + service RollingFileAppender(String name, Path file, RollingPolicy policy, Layout layout, Filter[] filters = []) implements Appender; + service AsyncAppender (String name, Appender delegate, Int queueSize = 1024) implements Appender; + + // shipped layouts + service PatternLayout(String pattern) implements Layout; + service JsonLayout implements Layout; + + // shipped filters + service LevelFilter (log.Level threshold) implements Filter; + service MarkerFilter(log.Marker required) implements Filter; + service MDCFilter (String key, String valuePrefix) implements Filter; +} +``` + +## Sketch — programmatic configuration + +```ecstasy +module MyApp { + package log import logging.xtclang.org; + package logback import logback.xtclang.org; + + void run() { + @Inject logback.Configurator config; + + logback.PatternLayout text = new logback.PatternLayout( + "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"); + + logback.JsonLayout json = new logback.JsonLayout(); + + config + .setRootLevel(log.Info) + .setLevel("com.example.payments", log.Debug) + .addAppender(new logback.ConsoleAppender("STDOUT", text)) + .addAppender(new logback.RollingFileAppender( + name = "FILE", + file = Path:/var/log/app.log, + policy = new logback.SizeBasedRollingPolicy(maxSize = 100M, maxBackups = 10), + layout = json, + filters = [new logback.MarkerFilter(MarkerFactory.getMarker("AUDIT"))] + )); + } +} +``` + +## Sketch — file-based configuration + +For parity with Logback's `logback.xml`, the module could ship an XML or JSON loader. +A possible JSON shape (more idiomatic for Ecstasy than XML): + +```json +{ + "rootLevel": "INFO", + "loggers": { + "com.example.payments": "DEBUG", + "com.example.silly": "OFF" + }, + "appenders": [ + { + "name": "STDOUT", + "type": "ConsoleAppender", + "layout": { "type": "PatternLayout", "pattern": "%d [%thread] %-5level %logger - %msg%n" } + }, + { + "name": "FILE", + "type": "RollingFileAppender", + "file": "/var/log/app.log", + "policy": { "type": "SizeBased", "maxSize": "100M", "maxBackups": 10 }, + "layout": { "type": "JsonLayout" }, + "filters": [ + { "type": "MarkerFilter", "marker": "AUDIT" } + ] + } + ] +} +``` + +Loading this via `lib_json`: + +```ecstasy +@Inject FileSystem fs; +@Inject json.Schema schema; // (sketch — actual API depends on lib_json) +@Inject logback.Configurator config; + +File configFile = fs.fileFor(Path:/etc/myapp/logging.json); +String text = configFile.contents.toString(); +LoggingConfig cfg = schema.deserialize(LoggingConfig, text); +config.applyConfig(cfg); +``` + +## Hot reload + +Logback's auto-reload watches the config file and rebuilds the logger context on change. +The Ecstasy equivalent would use the file-system change-notification API to do the same. +The atomic swap is on the `Configurator` side; once it commits a new config, every +existing `Logger` reference (since it goes through the injected sink) sees the new +behaviour on the next call. + +## Per-logger lookup + +The longest-prefix-match lookup illustrated in `CUSTOM_SINKS.md` is the engine: + +```ecstasy +private Level effectiveLevel(String loggerName) { + String name = loggerName; + while (True) { + if (Level lvl := perLogger.get(name)) { + return lvl; + } + Int dot = name.lastIndexOf('.'); + if (dot <= 0) { + return rootLevel; + } + name = name[0 ..< dot]; + } +} +``` + +This is what `LoggerContext.getLogger(name)` does in Logback, just in fewer lines +because we don't have to maintain a tree object. + +## Async appenders + +```ecstasy +service AsyncAppender(String name, Appender delegate, Int queueSize) + implements Appender { + + private Queue queue = new BlockingQueue(queueSize); + + construct(String name, Appender delegate, Int queueSize) { + // start a worker fiber that drains the queue into the delegate + worker.runAsync(drainLoop); + } + + @Override + void append(log.LogEvent event) { + if (!queue.offer(event)) { + // queue full: drop or block — configurable via policy + } + } + + private void drainLoop() { + while (running) { + log.LogEvent event = queue.take(); + delegate.append(event); + } + } +} +``` + +This is the Ecstasy-native equivalent of Logback's `AsyncAppender`. Fibers replace +threads; otherwise the design is identical. + +## Migration story for SLF4J + Logback users + +A team running an SLF4J + Logback service today and porting to Ecstasy + `lib_logging` + +`lib_logging_logback`: + +| In Java + Logback | In Ecstasy + lib_logging_logback | +|---|---| +| `org.slf4j.LoggerFactory.getLogger(MyClass.class)` | `@Inject Logger logger;` (or `LoggerFactory.getLogger(MyClass)`) | +| `logback.xml` in classpath root | `logging.json` (or programmatic) loaded by `Configurator` | +| `` | `config.setRootLevel(Info)` | +| `` | `config.setLevel("com.example.foo", Debug)` | +| `` | `new ConsoleAppender("STDOUT", layout)` | +| `` | `new PatternLayout(pattern)` | +| `` | `new LevelFilter(threshold)` | +| `MDC.put("requestId", id)` | `mdc.put("requestId", id)` | +| `org.slf4j.MarkerFactory.getMarker("AUDIT")` | `markers.getMarker("AUDIT")` | + +Caller code is unchanged. Configuration is structurally similar but expressed in +Ecstasy idiom. + +## Out of scope for `lib_logging_logback` v1 + +These are explicitly later: + +- SMTP appender (email on errors). +- DBAppender (write to RDBMS). +- Sift appender (separate appender per MDC value). +- TurboFilters (filters that run *before* the level check; meaningful only for very + high-volume systems). + +These exist in Java Logback. They are infrequently used and can be added incrementally +without API changes. diff --git a/doc/logging/NATIVE_BRIDGE.md b/doc/logging/NATIVE_BRIDGE.md new file mode 100644 index 0000000000..5bd7ac276e --- /dev/null +++ b/doc/logging/NATIVE_BRIDGE.md @@ -0,0 +1,227 @@ +# Native bridge — could we plug real Java logging libraries in? + +A research document. Investigates whether it's technically feasible — and whether it's +*advisable* — to ship `lib_logging` with a `LogSink` whose implementation is *native +Java code* wrapping the real `slf4j-api` / `logback-classic` libraries via Ecstasy's +existing JIT bridge. + +The short answer: + +- **Feasibility: high.** The bridge already does this for `jline`. Adding SLF4J + Logback + is a small engineering task. +- **Recommendation: don't make it the primary path.** The pure-Ecstasy `LogSink` + design wins on portability, debuggability, and surface-area minimisation. A native + bridge is interesting as an *escape hatch* for users who want to use existing Java + Logback configuration assets unchanged. Document it; design for it; don't ship it as + the default. + +The rest of this doc walks through the evidence, the trade-offs, and what a native +bridge would look like if we built one. + +## Evidence the bridge can carry external Java dependencies + +Ecstasy's runtime in `javatools_jitbridge/` already contains worked examples of native +services delegating to Java libraries. + +### TerminalConsole wraps `jline` + +The Ecstasy `Console` interface (`lib_ecstasy/src/main/x/ecstasy/io/Console.x`) is +implemented natively by `javatools_jitbridge/src/main/java/org/xtclang/_native/io/TerminalConsole.java`. +That class extends `nService` (the native-service base) and delegates to `xTerminalConsole`, +which imports `org.jline.reader` and `org.jline.terminal` from the third-party `jline` +library (declared in `gradle/libs.versions.toml` as `jline = "4.0.12"`). + +This proves three things: + +1. The bridge accepts third-party Java dependencies. +2. The build picks them up cleanly through the version catalog. +3. The pattern of "Ecstasy interface → native `nService` subclass → external Java + library" works end-to-end. + +### SLF4J and Logback are already in the catalog + +`gradle/libs.versions.toml`: + +```toml +lang-logback = "1.5.32" +lang-slf4j = "2.0.17" + +lang-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "lang-slf4j" } +lang-logback = { module = "ch.qos.logback:logback-classic", version.ref = "lang-logback" } +``` + +These are declared today for the lang/IDE tooling. They aren't currently consumed by +`javatools_jitbridge`, but adding them to `javatools_jitbridge/build.gradle.kts` is a +two-line change. + +## How native services bind to Ecstasy interfaces + +Concretely, the binding pattern (from inspecting `TerminalConsole.java`): + +- The Ecstasy `Console` interface declares `void print(Object object = "", Boolean + suppressNewline = False)`. +- The Java side implements a method `print$p(Ctx ctx, nObj object, boolean + suppressNewline, boolean dfltSuppressNewline)`. The `$p` suffix is convention; the + trailing `dflt*` booleans tell the native code which arguments were defaulted vs + passed explicitly. +- Argument marshalling: `String` becomes a custom Ecstasy `String` (UTF-8/compressed), + `Object` becomes `nObj`, primitive `Boolean` becomes Java `boolean`. To convert an + Ecstasy `Object` to a Java `String`, the native code calls `obj.toString(ctx)`. + +The bridge has no example yet of a native method receiving a heterogeneous `Object[]` +or invoking back into Ecstasy code. The architecture supports it (via +`nFunction.stdMethod.invokeExact`); it just hasn't been used. + +## Resource registration + +`javatools_jitbridge/src/main/java/org/xtclang/_native/mgmt/nMainInjector.java` line ~51: + +```java +suppliers.put(new Resource(consoleType, "console"), TerminalConsole::$create); +``` + +For `lib_logging`, we'd add: + +```java +suppliers.put(new Resource(loggerType, "*"), RTLogger::$create); +suppliers.put(new Resource(logSinkType, "default"), RTLogbackSink::$create); +``` + +The wildcard `"*"` for `Logger` is the workaround for the current exact-name resolution +(see `OPEN_QUESTIONS.md` — this is a small change to `nMainInjector.supplierOf`). + +The suppliers map is per-Injector instance, so each container can choose its own +sink. There is no JVM-global state; this matches Ecstasy's container isolation +philosophy. + +## What a native Logback-backed sink would look like + +`javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogbackSink.java`: + +```java +public class RTLogbackSink extends nService implements LogSink { + + public static RTLogbackSink $create(Ctx ctx, nObj opts) { + return new RTLogbackSink(); + } + + public boolean isEnabled$p( + Ctx ctx, + org.xtclang.ecstasy.text.String loggerName, + nObj level, + nObj marker, + boolean dfltMarker) { + org.slf4j.Logger l = org.slf4j.LoggerFactory.getLogger(loggerName.toString(ctx)); + return switch (toJavaLevel(level)) { + case TRACE -> l.isTraceEnabled(); + case DEBUG -> l.isDebugEnabled(); + case INFO -> l.isInfoEnabled(); + case WARN -> l.isWarnEnabled(); + case ERROR -> l.isErrorEnabled(); + }; + } + + public void log$p(Ctx ctx, nObj eventObj) { + // unpack eventObj into (loggerName, level, message, marker, exception, mdc, ts) + // call org.slf4j.LoggerFactory.getLogger(name).atLevel(level) + // .addMarker(...).setCause(...).log(message); + } +} +``` + +This wraps `org.slf4j.Logger` directly. Logback (already on the classpath) handles +configuration, filtering, appenders, layouts, async, rolling files — everything from +its existing `logback.xml`. + +For an Ecstasy host running this sink: +- Existing `logback.xml` files work as-is. +- Existing Logback expertise transfers directly. +- Existing operational tooling (log shippers expecting Logback's wire formats) keeps + working. + +## Trade-offs + +### Pros of going native + +| Pro | Detail | +|---|---| +| **Zero re-implementation cost** | We get Logback's filters, layouts, async, rolling, MDC rendering for free. The Java SLF4J/Logback ecosystem is mature; reproducing a fraction of it in pure Ecstasy is months of work. | +| **Existing config files just work** | Teams porting from Java keep their `logback.xml` and operational dashboards. | +| **Performance** | Logback is heavily tuned. Pure-Ecstasy reimplementations would take time to match. | +| **Battle tested** | Decades of production use means corner cases (rolling file mid-write, broken pipe on a TCP appender, GC pause behaviour) are known and handled. | + +### Cons of going native + +| Con | Detail | +|---|---| +| **JVM lock-in** | `lib_logging` becomes JVM-only at the native-sink layer. A future Ecstasy runtime (native, WASM, embedded) needs an equivalent native sink for that platform, or has to fall back to a pure-Ecstasy sink. | +| **Marshalling cost** | Every log call crosses the bridge: `Object → nObj`, then `nObj.toString(ctx)` to materialize a Java `String`. For high-volume disabled-level calls this is a measurable hit even after the level check. (Disabled calls don't cross; that's fine. The cost is on enabled calls.) | +| **Surface area** | Logback brings in dozens of classes, transitive deps, and configuration knobs we don't control. A bug in Logback becomes an Ecstasy-runtime bug from the user's perspective. | +| **Debug story** | Ecstasy stack traces stop at the bridge boundary; the inside of Logback shows up as opaque Java frames. Users debugging a misconfigured appender see Java-shaped errors, not Ecstasy-shaped ones. | +| **Configuration model coupling** | If we ship a native Logback sink, users will write `logback.xml`. If a future pure-Ecstasy `lib_logging_logback` ships, they have two ways to configure logging that look the same but don't interoperate. | +| **Bootstrap weirdness** | Logback initialises during classloading. The order of operations between Logback's static init and the Ecstasy injector's `addNativeResources()` call is fragile — solvable, but a footgun. | +| **Dependency churn** | We've now made every Ecstasy program depend transitively on `slf4j-api` + `logback-classic` (~3 MB of JARs). For programs that just want to log to console, that's overkill. | + +### Marshalling cost — concrete sketch + +For an enabled `info` call, the per-call cost crossing the bridge once would be roughly: + +- One nObj→Java String conversion for the logger name. +- One nObj→Java String conversion for the message (after Ecstasy-side `MessageFormatter` + does the substitution). +- One Object[]→Java `Object[]` conversion if SLF4J 2.x's `KeyValuePair` story is used. +- One MDC map copy across the bridge if the sink wants MDC. + +All allocating, all per-call. For a service emitting 10K log lines per second the +overhead is in the milliseconds-per-second range — non-trivial but not catastrophic. +For most applications the level check screens out the bulk of calls anyway. + +## Recommendation + +Three options, ordered by recommendation: + +### Option A (recommended): pure-Ecstasy `LogSink` is the primary path + +Ship `ConsoleLogSink` as the default. Ship `lib_logging_logback` (the future module +described in `LOGBACK_INTEGRATION.md`) as a pure-Ecstasy implementation. + +A native sink, if we ever ship one, is *one* of multiple `LogSink` choices, not the +default. Users who want the Java Logback experience opt in explicitly. + +This keeps `lib_logging` portable (any Ecstasy runtime, not just JVM-hosted), keeps +the dependency graph small, and keeps stack traces uniform. + +### Option B: native sink as a separate optional module + +Ship `lib_logging_native_logback` (or similar) as an optional XDK lib that pulls in +SLF4J + Logback. The runtime injector picks it up if present. Users who need +production-grade Logback today can use this; users who don't aren't paying for it. + +Concretely: a new lib_logging_native_logback module, a new +`javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogbackSink.java`, a +small change to `nMainInjector` to register it conditionally. Estimated work: under a +week of focused effort once the pure-Ecstasy `lib_logging` v0 is solid. + +### Option C: native sink as the default + +Don't recommend. It's the path of least immediate work but it pins us to the JVM and +inherits Logback's surface area into Ecstasy's contract. Hard to undo. + +## Conclusion + +The native bridge angle is worth knowing about because: + +1. It's technically feasible — the `jline` precedent is direct evidence. +2. It's a useful escape hatch for teams porting JVM workloads who have heavy + investment in `logback.xml` configurations they don't want to rewrite. +3. The existence of the option *constrains the API design* of `lib_logging` itself — + anything in the SLF4J 2.x surface that a native sink would want to forward to + `org.slf4j.Logger.atInfo()...log()` should be expressible in our `LoggingEventBuilder`. + We've designed for that. + +But: the long-term answer is a pure-Ecstasy implementation. We get there incrementally: +ship the API, ship a small default sink, ship a logback-equivalent module written in +Ecstasy, optionally ship a native bridge for legacy interop. + +The most important property is that none of these decisions break caller code. The +`Logger` interface and the `LogSink` boundary make all of this swappable. diff --git a/doc/logging/OPEN_QUESTIONS.md b/doc/logging/OPEN_QUESTIONS.md new file mode 100644 index 0000000000..0a743bafb1 --- /dev/null +++ b/doc/logging/OPEN_QUESTIONS.md @@ -0,0 +1,184 @@ +# Open questions for `lib_logging` + +A running list of unresolved design and implementation questions. Each entry includes +the trade-offs as currently understood and a tentative recommendation. None of these +are blockers for the v0 stub; they are blockers for "v0 actually works end-to-end". + +## 1. Wildcard-name injection in `nMainInjector` + +The runtime injection key is `(TypeConstant, String name)` and resolves by exact match. +For `@Inject Logger logger`, the empty/default name is fine. For +`@Inject("com.example.foo") Logger logger`, the name varies per call site and cannot be +exhaustively enumerated. + +**Options:** + +- **A. Wildcard match.** Make `nMainInjector.supplierOf` fall back to a wildcard entry + (`"*"`) when no exact match is found. The factory receives the requested name as + `opts` and bakes it into the proxy. This is one-method change. +- **B. Generalize Resource resolution.** Make `Resource` keys support type-only entries + with name passed through. Bigger change, broader blast radius. + +**Tentative recommendation:** A. + +## 2. Default logger name when no `resourceName` supplied + +`@Inject Logger logger;` has no name. What should the resulting logger's name be? + +**Options:** + +- **A. The literal string `"default"`.** Simple, predictable, but means every + injection-without-name across an application maps to the same logger. +- **B. The enclosing module's qualified name.** Requires the XTC compiler to substitute + the name at compile time. More useful for hierarchical logger configuration but is a + compiler-side change. +- **C. The fully-qualified name of the enclosing class/method.** Like SLF4J's + `LoggerFactory.getLogger(MyClass.class)`. Strictly the most useful default. + +**Tentative recommendation:** A for v0 (no compiler change). Track B/C as a follow-up +once the rest of the wiring is solid. + +## 3. MDC scope: per-fiber, per-service, or per-call? + +`MDC` carries context that should follow a request through nested calls without being +threaded explicitly. The right scope depends on Ecstasy's concurrency story. + +**Options:** + +- **A. Per-fiber.** Each fiber sees its own context. Matches Java's `ThreadLocal` MDC + semantics. Requires runtime support for per-fiber locals. +- **B. Per-service.** Every service instance has its own MDC. Simplest; matches the + `service` keyword's existing isolation model. +- **C. Explicit propagation.** Loggers carry their own MDC; deriving a logger derives + the context. This is the slog-style approach. + +**Tentative recommendation:** A if Ecstasy gives us per-fiber locals; B if not. C is the +"Logger.with" path documented in `ALTERNATIVE_DESIGN_SLOG_STYLE.md` and would be a +separate API addition rather than a replacement. + +## 4. Multiple markers per event + +SLF4J 2.x's `LoggingEventBuilder.addMarker` accepts repeated calls and stores all of +them on the event. Our `BasicEventBuilder` currently keeps only the most recently added +one. + +**Options:** + +- **A. Match SLF4J — list of markers per event.** Requires `LogEvent.marker` → + `Marker[] markers`. Affects `LogSink` callers everywhere. +- **B. Keep one marker, document the limitation.** Sinks that need multiple markers + can use child references on a single marker (the `Marker.add(other)` mechanism). + +**Tentative recommendation:** A — match SLF4J. The cost is a one-line type change and a +small loop in the few sinks that read it; doing this in v0 is much cheaper than +breaking callers later. + +## 5. Throwable promotion: where does it happen? + +SLF4J's rule: a trailing `Throwable` argument with no matching placeholder is treated as +the cause. Today our `MessageFormatter.format` returns `(formatted, cause?)` so the +formatter is the right place. But callers can also pass `cause` explicitly. Which wins? + +**Options:** + +- **A. Explicit `cause` always wins.** If the caller passed `cause=e`, ignore any + promoted-from-args throwable. +- **B. Promoted always wins.** Surprising; callers expect their explicit `cause` to be + honoured. +- **C. Error if both supplied.** Programming error. + +**Tentative recommendation:** A. Document explicitly. + +## 6. Service vs class for sinks + +Most `LogSink` implementations want to be services (mutable state — counters, queues, +file handles). Should the contract require it? + +**Options:** + +- **A. Recommend, don't require.** `LogSink` is an interface; implementations choose. +- **B. Require.** `interface LogSink extends Service` (or whatever the Ecstasy + equivalent is). + +**Tentative recommendation:** A. Forcing service-hood breaks the trivial cases +(`NoopLogSink` doesn't need it; testing helpers may not need it). Users who build +stateful sinks will reach for `service` naturally. + +## 7. Async / batched sinks + +The default sinks are synchronous. A real production system wants async to keep slow +I/O off the caller path. When and how do we ship one? + +**Options:** + +- **A. Ship `AsyncLogSink` as a wrapper in `lib_logging`.** Caller does + `new AsyncLogSink(new ConsoleLogSink())`. Worker fiber drains a bounded queue. +- **B. Defer to `lib_logging_logback`.** That module is the natural home for the + async-appender story; keeping `lib_logging` minimal is good. + +**Tentative recommendation:** B. Ship the basics in v0; async lives in the configurable +backend. + +## 8. Configuration override per container + +Ecstasy's container model allows nested containers each with their own injector. A +guest module nested in a host could in principle want a different logger sink than the +host. Today the `nMainInjector.suppliers` map is per-instance, which is the right +foundation. The question is the *user-facing API*: how does a host install a different +sink for one nested container? + +**Options:** + +- **A. Document that the host configures its child container's injector explicitly.** + No API addition. +- **B. Provide a helper, e.g. `ContainerLoggingConfig.set(child, sink)`.** + +**Tentative recommendation:** A for v0. Revisit if there's demand. + +## 9. Where does the runtime live? + +The `BasicLogger` and the sink classes are pure Ecstasy. The runtime piece — the actual +`@Inject Logger` resolution — needs Java code in `javatools_jitbridge`. The directory +layout question: + +- `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogger.java` +- `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogSink.java` +- Registration in `javatools_jitbridge/src/main/java/org/xtclang/_native/mgmt/nMainInjector.java` + +Convention to confirm with a maintainer; mirrors `_native/io/TerminalConsole.java`. + +## 10. Default sink: pure-Ecstasy or a thin native bootstrap? + +The default `ConsoleLogSink` is written in Ecstasy and goes through `@Inject Console`. +Bootstrap-wise, this means the sink resolution has to happen *after* `Console` is +registered. This is fine in practice (both are registered in `addNativeResources`) but +it's an ordering constraint worth noting. + +If for any reason `ConsoleLogSink` cannot be resolved during early-runtime logging, +should there be a tiny native fallback that `System.err.println`s? SLF4J does this with +`SubstituteLoggerFactory`. + +**Tentative recommendation:** Yes, a tiny native fallback. It's a few lines and removes +an entire class of "logger isn't ready yet" failure mode. + +## 11. LogEvent immutability and `Object[]` arguments + +`LogEvent` is a `const`. The `arguments: Object[]` field, however, holds caller-supplied +references. If the caller mutates the array between `Logger.info(...)` returning and an +async sink consuming the event, the sink sees the mutation. + +**Options:** + +- **A. Defensive copy at the `BasicLogger.emit` boundary.** One allocation per emission. +- **B. Document that callers must not mutate the array.** Match SLF4J's posture (which + is the same). + +**Tentative recommendation:** B for v0. Reconsider if async sinks become common. + +## 12. Compiler/tooling support for log statements + +SLF4J has `slf4j-tools` that lints for things like `info("count: " + n)` (eagerly +formatted instead of using `{}`) and missing exception arguments. Would Ecstasy benefit? + +**Tentative recommendation:** Out of scope for `lib_logging`. If we want it, it's an +XTC compiler/linter feature, not a library feature. diff --git a/doc/logging/PLAN.md b/doc/logging/PLAN.md new file mode 100644 index 0000000000..431040c656 --- /dev/null +++ b/doc/logging/PLAN.md @@ -0,0 +1,123 @@ +# lib_logging — Plan + +This is the master plan for the experimental `lib_logging` module on the +`lagergren/logging` branch. It captures scope, non-goals, ordering of work, and the +explicit decisions the design is built on. + +## Goals (in priority order) + +1. **Instant familiarity for SLF4J users.** Anyone who has used SLF4J 2.x in Java should + be able to read Ecstasy logging code and immediately know what it does. Same level set, + same `{}` parameter substitution semantics, same throwable promotion rule, same + marker / MDC concepts, same fluent event builder shape. + +2. **Simple to use.** `@Inject Logger logger;` should be all most users ever write to get + a working logger. No boilerplate factory calls, no static initializers, no compile-time + config files in the default path. + +3. **Same API/impl boundary as SLF4J.** `slf4j-api` is one jar; `slf4j-simple`, + `logback-classic`, `log4j2-slf4j` are the bindings. We want the same separability so the + default ships in the box but a richer backend can be swapped in without changing caller + code. The boundary is the `LogSink` interface. + +4. **Compatible with Ecstasy's injection model.** Loggers are obtained via + `@Inject Logger`, optionally with a name (`@Inject("foo") Logger`). The platform/runtime + controls which sink is wired up — same posture as `Console`, `Clock`, `Random`. + +5. **Minimum-viable surface includes the things people *expect* even if rarely used.** + Specifically: markers, MDC, fluent event builder. Default implementations may be + no-ops, but the types must exist so user code that uses them compiles and runs against + the default sink without modification. + +6. **Future-ready for a logback-style backend.** Configuration-driven appenders, layouts, + filters, hierarchical per-logger thresholds. Not implemented now, but designed-for so + it's a swap-in (`LogSink` impl) rather than an API rewrite. + +## Non-goals (for the v0 stub) + +- Distributed tracing context propagation. (MDC carries strings; that's it.) +- Async / batched / buffered sinks. The contract allows them; the default doesn't do them. +- Configuration file format. The default sink has one knob (`rootLevel`) and that's it. + A real config story belongs to the future logback-style backend module. +- Compile-time logger-name defaulting from the enclosing module. See `OPEN_QUESTIONS.md`. + +## Module layout + +``` +lib_logging/ + build.gradle.kts + src/main/x/ + logging.x module declaration + logging/ + Level.x + Marker.x + BasicMarker.x + MarkerFactory.x + MDC.x + LogEvent.x + LogSink.x + Logger.x + LoggingEventBuilder.x + LoggerFactory.x + MessageFormatter.x + BasicLogger.x + BasicEventBuilder.x + ConsoleLogSink.x + NoopLogSink.x + MemoryLogSink.x + docs/ + PLAN.md this file + DESIGN.md architecture + SLF4J_PARITY.md API mapping + ECSTASY_VS_JAVA_EXAMPLES.md side-by-side + CUSTOM_SINKS.md + LOGBACK_INTEGRATION.md + NATIVE_BRIDGE.md + OPEN_QUESTIONS.md +``` + +The boundary between API and implementation is `LogSink.x`. Everything above is the +public, stable API; everything below is replaceable. + +## Ordering of work + +This is the order I expect the work to land in. The repository currently sits at the end +of step 2. + +1. **Build skeleton.** `build.gradle.kts`, empty `logging.x`, registration in + `xdk/settings.gradle.kts`, `gradle/libs.versions.toml`, `xdk/build.gradle.kts`. + ✅ Done. +2. **Stub the API.** `Level`, `Marker`, `MarkerFactory`, `MDC`, `LogEvent`, `LogSink`, + `Logger`, `LoggingEventBuilder`, `LoggerFactory`, `MessageFormatter`. All compile; + bodies have `TODO(impl)` markers where appropriate. ✅ Done (modulo Ecstasy syntax + review — see `OPEN_QUESTIONS.md`). +3. **Stub the default impls.** `BasicMarker`, `BasicLogger`, `BasicEventBuilder`, + `ConsoleLogSink`, `NoopLogSink`, `MemoryLogSink`. ✅ Done. +4. **Make it build.** Run `./gradlew :lib_logging:build`, fix any Ecstasy syntax issues, + confirm distribution build still works. **In progress.** +5. **Real `MessageFormatter`.** Port the SLF4J state machine for `{}` substitution + including escape rules and throwable promotion. Test against SLF4J's published cases. +6. **Runtime injection plumbing.** Java side. Add a `RTLogger` native service in + `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/` and register it in + `nMainInjector.addNativeResources()`. See `DESIGN.md` and `OPEN_QUESTIONS.md` — there + is a wildcard-name lookup change required. +7. **Sample under `manualTests/`.** A throwaway program that does + `@Inject Logger logger; logger.info(...)` end-to-end through the default + `ConsoleLogSink`. +8. **MDC story.** Decide whether MDC entries are fiber-local or service-instance-local + and document the decision; update the `MDC` service to match. +9. **Compiler-side default-name** (optional). When a user writes `@Inject Logger logger;` + with no resourceName, have the XTC compiler substitute the enclosing module's + qualified name. Strict ergonomics improvement; not blocking. +10. **Future module: `lib_logging_logback`.** Separate module shipping a + configuration-driven `LogSink` with appenders, layouts, filters, per-logger + thresholds. See `LOGBACK_INTEGRATION.md`. + +## Validation checklist (done = "v0 ready to merge") + +- [ ] `./gradlew clean` then `./gradlew :lib_logging:build` succeeds. +- [ ] `./gradlew xdk:installDist` still succeeds and bundles `lib_logging.xtc`. +- [ ] A `manualTests/` sample emits an `info` and `error` event to the console. +- [ ] `MemoryLogSink` test confirms the events captured contain the right + `(loggerName, level, message, exception, marker)`. +- [ ] All existing XDK tests still pass. diff --git a/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md b/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md new file mode 100644 index 0000000000..cf858998f5 --- /dev/null +++ b/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md @@ -0,0 +1,272 @@ +# How `lib_logging` would change `~/src/platform` and `~/src/examples` + +This is a forward-looking doc surveying two real Ecstasy code bases that already exist +side by side with the XDK in this dev environment, and showing how each would adopt +`lib_logging` if it shipped as part of the XDK today. The point is to validate the API +choices against actual code, not invented snippets. + +The repos surveyed: + +- `~/src/platform/` — the XTC platform host (kernel, account/host managers, OAuth + proxy, CLI tools). +- `~/src/examples/` — the demo apps (CardGame, welcome, shopping, chess-game). + +## What they do today + +Both repos use `@Inject Console console;` and write log-shaped lines manually. They +have invented their own ad-hoc logging vocabularies, and they do not all agree. + +### `~/src/platform/` + +The platform code logs heavily and has crystallised a convention: every log line is +prefixed `Info :`, `Warn :`, or `Error:` and timestamped with a helper from +`common.logTime`. + +`platform/common/src/main/x/common.x:47`: + +```ecstasy +static String logTime(Time? time = Null) { ... } +``` + +Used in `platform/kernel/src/main/x/kernel.x` ~13 times like this: + +```ecstasy +console.print($"{common.logTime($)} Info : Starting the AccountManager..."); +console.print($|{common.logTime($)} Warn : Failed to load the ProxyManager; new \ + instance will be initialized + ); +``` + +`platform/auth/src/main/x/auth/OAuthProvider.x` has 12 such call sites: + +```ecstasy +console.print($"Error: Authentication request to {provider} has timed out"); +console.print("Error: Authentication request rejected: too many attempts"); +console.print($"Info : Requesting authorization from {provider}"); +``` + +`platform/host/src/main/x/host/HostManager.x` collects deferred messages into an +in-memory error list and replays them later: + +```ecstasy +errors.add($|Info : Deployment "{webAppInfo.deployment}" is already active); +// ... +errors.reportAll(msg -> console.print(msg)); +``` + +This is what every codebase that has *almost* invented a logging library looks like. + +### `~/src/examples/` + +The demo apps print to the console in the same way but without the `Info :`/`Warn :` +prefix. Each `Eights` game turn does: + +```ecstasy +console.print($"{player.name} plays {card}"); +console.print($"{player.name} cannot play and passes."); +console.print($"{player.name} draws {card}"); +``` + +These aren't really log lines — they're program output. But they share the same shape +as the platform's `console.print` calls and would benefit from the same separation +between "intentional user-facing output" (`Console`) and "diagnostic event" +(`Logger`). + +## What would change with `lib_logging` + +Three categories of change, depending on what each `console.print` is actually for. + +### 1. Diagnostic logging → `Logger` + +This is the platform repo's bread and butter. The migration is mechanical: + +**Before** (`platform/kernel/src/main/x/kernel.x:149`): + +```ecstasy +@Inject Console console; + +void start() { + console.print($"{common.logTime($)} Info : Starting the AccountManager..."); + // ... + if (!loaded) { + console.print($|{common.logTime($)} Warn : Failed to load the ProxyManager; new \ + instance will be initialized + ); + } + if (errored) { + errors.reportAll(msg -> console.print(msg)); + } +} +``` + +**After**: + +```ecstasy +@Inject("kernel") log.Logger logger; + +void start() { + logger.info("Starting the AccountManager..."); + // ... + if (!loaded) { + logger.warn("Failed to load the ProxyManager; new instance will be initialized"); + } + if (errored) { + errors.reportAll(msg -> logger.error(msg)); + } +} +``` + +What we get: +- The timestamp goes away from the call site (the sink renders it). +- `Info :`/`Warn :`/`Error:` text disappears (the sink renders it). +- `common.logTime` becomes dead code; same for any duplicate of it elsewhere. +- The platform host can choose at deploy time whether to render plain text, JSON, or + ship to a remote aggregator — *without changing any caller*. + +### 2. The deferred-error pattern → markers + a memory sink + +The pattern in `host/HostManager.x` — collect errors into a list, replay them later — +maps cleanly onto the `MemoryLogSink` shipped in `lib_logging`. Each `errors.add(...)` +becomes `auditLogger.error(...)` against a logger whose sink is a memory-backed buffer +that the host drains on completion. + +```ecstasy +log.MemoryLogSink deploymentLog = new log.MemoryLogSink(); +log.Logger deployLogger = new log.BasicLogger("host.deploy", deploymentLog); + +// ... do the work ... + +// On completion: +for (log.LogEvent event : deploymentLog.events) { + console.print(event.message); +} +``` + +If categorical filtering matters ("show only audit events to ops"), markers do that: + +```ecstasy +log.Marker AUDIT = markers.getMarker("AUDIT"); +deployLogger.atInfo() + .addMarker(AUDIT) + .log("Deployment \"{}\" is already active", webAppInfo.deployment); +``` + +### 3. Genuine user-facing output → stays on `Console` + +Examples like `~/src/examples/CardGame/src/main/x/cardGame/Eights.x`: + +```ecstasy +console.print($"{player.name} plays {card}"); +``` + +This is *user output*, not a log event. It stays on `Console`. The split is healthy: +`Console` is for "things the user is intentionally meant to see on stdout"; `Logger` is +for "diagnostic events the operator might be interested in". A logging library that is +shaped like SLF4J makes that distinction obvious. + +Mixed cases — say, a CLI that wants progress output during normal runs but verbose +diagnostics under `--verbose` — naturally split into "console for the progress lines, +logger for the diagnostics". CLI tools at any scale benefit from the split. + +## Worked migration: `auth/OAuthProvider.x` + +This is the worst-offending file in the platform repo by call-site count. Twelve +`console.print` lines that all begin with `Info :` / `Error:`. After migration: + +```ecstasy +service OAuthProvider { + @Inject("auth.oauth") log.Logger logger; + + void timeout(String provider) { + logger.error("Authentication request to {} has timed out", [provider]); + } + + void rejected() { + logger.error("Authentication request rejected: too many attempts"); + } + + void requesting(String provider) { + logger.info("Requesting authorization from {}", [provider]); + } + + void crossSiteForgery() { + logger.error("Cross-site forgery detected"); + } + + // ... +} +``` + +The lines are shorter, the level is now structured (a sink can filter on it), the file +no longer threads `Console` through every method, and the operator gets to pick +whether the output goes to stdout, a file, or a remote sink. + +## What lands in the platform repo's `common.x` + +Today `common.x` exports `logTime`. Post-migration, that helper goes away, but the +package gains a small canonical `Logger` per subsystem: + +```ecstasy +package common { + package log import logging.xtclang.org; + + @Inject("platform.kernel") log.Logger kernelLogger; + @Inject("platform.auth") log.Logger authLogger; + @Inject("platform.host") log.Logger hostLogger; + @Inject("platform.proxy") log.Logger proxyLogger; + @Inject("platform.cli") log.Logger cliLogger; +} +``` + +…or each subsystem injects its own logger directly without going through `common`. +Either is fine; the point is that *every* subsystem now has a named logger and there +is no string-prefix convention to maintain. + +## What this means concretely for each repo + +### Platform repo + +- **Files touched:** roughly 10 — anywhere a `console.print($"... Info :")` / + `console.print($"... Error:")` exists. +- **Net code change:** roughly -50 lines (no more timestamp prefix, no more level + string, often no more `console` injection in files that only used it for logging). +- **Configuration model:** until `lib_logging_logback` ships, the default + `ConsoleLogSink` keeps existing operator dashboards working (it produces line- + oriented output). When it ships, the platform gets per-subsystem level control, + rolling files, JSON output for log shippers — none of which it has today. +- **Migration risk:** very low. Each call-site is independent; migrate file by file. +- **Side benefit:** `MemoryLogSink` replaces every ad-hoc `errors.add(...)` / + `errors.reportAll(...)` pattern with one library helper. + +### Examples repo + +- **Files touched:** few. Most `console.print` calls are genuine user output; only + the *non-game-output* messages (test setup, error reporting from the CardGame + shuffler, validation messages in `shopping/cart_api.x`) become logger calls. +- **Net code change:** roughly neutral. The intent is clarity, not LOC. +- **Educational value:** the examples become canonical demonstrations of the API/impl + split — `welcome` could ship with `ConsoleLogSink` by default and `MemoryLogSink` + in tests, side by side. That's a useful lesson for a "new to Ecstasy" reader. + +## Open question + +`lib_logging` is itself an XDK lib (`logging.xtclang.org`). The platform and examples +repos consume the XDK as a published artifact. To use `lib_logging` they need: + +- The next XDK release to ship with the new module included (already done in this + branch — see `xdk/build.gradle.kts` and `xdk/settings.gradle.kts`). +- Each repo's `build.gradle.kts` to add no new dependencies (the XDK already includes + it transitively) — they just `package log import logging.xtclang.org;` and use it. + +That's it. There is no separate Maven coordinate or jar to add. This is the upside of +shipping logging as part of the XDK: every Ecstasy program everywhere gets it for +free. + +## Recommendation + +When `lib_logging` v0 lands and the runtime injection wiring is in place, do a single +PR against the platform repo migrating `kernel.x`, `auth/OAuthProvider.x`, and +`host/HostManager.x` to `Logger`. That covers ~80% of the platform's logging surface +in one chunk and validates the API end-to-end on real code. The examples repo can +follow opportunistically, file by file — there is no urgency, and the gains there are +educational rather than operational. diff --git a/doc/logging/README.md b/doc/logging/README.md new file mode 100644 index 0000000000..3277782e10 --- /dev/null +++ b/doc/logging/README.md @@ -0,0 +1,166 @@ +# Ecstasy `lib_logging` — start here + +This directory holds the design and discussion docs for `lib_logging`, the experimental +SLF4J-shaped logging library being added to the XDK on the `lagergren/logging` branch. +If you are looking at the project for the first time, **this README is the right place +to land first**: it explains what the project is, who it is for, and where to go next +depending on what you want to do. + +## What `lib_logging` is in one paragraph + +A logging library for Ecstasy that is *instantly familiar* to anyone who has used SLF4J +2.x in Java — same level set, same `{}`-substituted parameterized messages, markers, +MDC, the SLF4J 2.x fluent event builder — but acquired the Ecstasy way: by +`@Inject Logger logger;`. The default sink writes to the platform `Console`; a clean +SPI (`LogSink`) is the API/impl boundary so richer backends (file, network, JSON, +configuration-driven Logback-style) can be dropped in without touching caller code. + +The corresponding code is in [`lib_logging/`](../../lib_logging) at the repo root. + +## What you'll find in this directory + +The docs are organised by question. Pick the one that matches what you want to know: + +### "Does this look like the rest of the XDK?" + +- **[`XDK_ALIGNMENT.md`](XDK_ALIGNMENT.md)** — surveys other XDK libs (`lib_ecstasy`, + `lib_cli`, `lib_crypto`, `lib_json`, etc.) and their conventions for injection, the + shareable-value mixins (`Freezable`/`Stringable`/`Hashable`/`Orderable`), and the + `const`/`service`/`class` split — and shows how `lib_logging` was aligned. + +### "What is this and why are we doing it?" + +- **[`PLAN.md`](PLAN.md)** — the master plan: scope, non-goals, ordering of work, + validation checklist. Read this first if you want the executive summary. +- **[`WHY_SLF4J_AND_INJECTION.md`](WHY_SLF4J_AND_INJECTION.md)** — the rationale + doc. Argues at length why modeling on SLF4J *and* on Ecstasy injection is the right + combination, and why the alternatives (JUL, log4j, slog, "roll our own") all fall + short in specific, predictable ways. +- **[`DESIGN.md`](DESIGN.md)** — the architecture. Module layout, the API↔impl + boundary, what each type does, how the runtime injection flows. + +### "How does this look in practice?" + +- **[`INJECTED_LOGGER_EXAMPLE.md`](INJECTED_LOGGER_EXAMPLE.md)** — a complete working + example of `@Inject Logger` end to end, with a runnable companion at + `manualTests/src/main/x/TestLogger.x`. Read this before everything else if you + just want to see what it looks like. +- **[`ECSTASY_VS_JAVA_EXAMPLES.md`](ECSTASY_VS_JAVA_EXAMPLES.md)** — for every + feature, a working SLF4J Java example followed immediately by the equivalent + Ecstasy code. Use this to onboard SLF4J users. +- **[`SLF4J_PARITY.md`](SLF4J_PARITY.md)** — exhaustive type-by-type and method-by- + method mapping from SLF4J 2.x to `lib_logging`. Reference document, not narrative. +- **[`PLATFORM_AND_EXAMPLES_ADAPTATION.md`](PLATFORM_AND_EXAMPLES_ADAPTATION.md)** — + surveys the existing `~/src/platform` and `~/src/examples` repos and shows + concretely how their current ad-hoc `console.print($"... Info :")` patterns would + migrate to `lib_logging`. + +### "How would I extend it?" + +- **[`CUSTOM_SINKS.md`](CUSTOM_SINKS.md)** — guide to writing your own `LogSink`, + with worked examples (counting sink, file sink, hierarchical sink, tee, marker + filter, JSON-line sink). Read this if you're adding a backend. +- **[`STRUCTURED_LOGGING.md`](STRUCTURED_LOGGING.md)** — how SLF4J 2.x structured + logging (key/value pairs, fluent builder) maps onto `lib_logging`, including a + sketch of the future structured-event data model and a `JsonLineLogSink`. +- **[`LAZY_LOGGING.md`](LAZY_LOGGING.md)** — exploration of Kotlin `kotlin-logging` + style lambda emission (`logger.info { "expensive ${x}" }`), what other languages + do, and three options for adding it to `lib_logging`. Includes an industry survey. + +### "What about a real production backend?" + +- **[`LOGBACK_INTEGRATION.md`](LOGBACK_INTEGRATION.md)** — sketch of the future + `lib_logging_logback` module: appenders, layouts, filters, per-logger thresholds, + programmatic and file-based configuration, hot reload, async appenders, and a + migration table for SLF4J + Logback users. +- **[`NATIVE_BRIDGE.md`](NATIVE_BRIDGE.md)** — could we instead plug *real Java + Logback* in via the JIT bridge? Investigation, evidence, trade-offs, and a + recommendation. Short answer: feasible but not the primary path. + +### "Could the design have looked different?" + +- **[`ALTERNATIVE_DESIGN_SLOG_STYLE.md`](ALTERNATIVE_DESIGN_SLOG_STYLE.md)** — + exploratory: what `lib_logging` would look like if modeled on Go's `log/slog` + instead of SLF4J. Code examples only, no actual implementation. Documents what + we'd be giving up and gaining if we ever revisit the choice. + +### "What's still uncertain?" + +- **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** — running list of unresolved design + and implementation questions, with the trade-offs and a tentative recommendation + for each. None block the v0 stub; they block "v0 working end-to-end". + +## Where the actual code lives + +In the [`lib_logging/`](../../lib_logging) directory at the repo root. + +``` +lib_logging/ +├── README.md short module-level intro, links back here +├── build.gradle.kts standard XTC plugin + xunit-engine for tests +└── src/ + ├── main/x/ + │ ├── logging.x module declaration + │ └── logging/ + │ ├── Level.x severity enum + │ ├── Marker.x marker interface (org.slf4j.Marker) + │ ├── BasicMarker.x default marker impl + │ ├── MarkerFactory.x factory service (org.slf4j.MarkerFactory) + │ ├── MDC.x mapped diagnostic context + │ ├── LogEvent.x immutable event const + │ ├── LogSink.x SPI — the API/impl boundary + │ ├── Logger.x user-facing facade (org.slf4j.Logger) + │ ├── LoggingEventBuilder.x SLF4J 2.x fluent builder + │ ├── LoggerFactory.x non-injection acquisition path + │ ├── MessageFormatter.x {}-substitution + throwable promotion + │ ├── BasicLogger.x canonical Logger implementation + │ ├── BasicEventBuilder.x canonical builder implementation + │ ├── ConsoleLogSink.x default sink — writes through @Inject Console + │ ├── NoopLogSink.x drops every event + │ └── MemoryLogSink.x captures events in memory (test helper) + └── test/x/ + └── LoggingTest/ + ├── ListLogSink.x linked-list-backed test sink + ├── LevelTest.x + ├── EmissionTest.x end-to-end per-level routing + ├── LevelCheckTest.x threshold / *Enabled property semantics + ├── MarkerTest.x marker containment + propagation + ├── FluentBuilderTest.x atInfo()/atError() builder tests + └── CountingSinkTest.x shows how to plug in a custom sink +``` + +Every `.x` source file has a header comment naming what it corresponds to in SLF4J, +Logback, Log4j 2, or JUL — so an SLF4J reader knows exactly which Java type each +Ecstasy type plays the role of. + +## How to read this doc tree depending on who you are + +| You are... | Start with | Then read | +|---|---|---| +| New to the project, want the gist | `PLAN.md` | `WHY_SLF4J_AND_INJECTION.md`, `DESIGN.md` | +| An SLF4J/Logback Java engineer | `ECSTASY_VS_JAVA_EXAMPLES.md` | `SLF4J_PARITY.md`, `LOGBACK_INTEGRATION.md` | +| A Go/slog engineer | `ALTERNATIVE_DESIGN_SLOG_STYLE.md` | `WHY_SLF4J_AND_INJECTION.md`, `STRUCTURED_LOGGING.md` | +| Adding a backend / sink | `CUSTOM_SINKS.md` | `STRUCTURED_LOGGING.md`, `LOGBACK_INTEGRATION.md` | +| Investigating performance / hot paths | `LAZY_LOGGING.md` | `OPEN_QUESTIONS.md` (entries on async/copy) | +| Reviewing the proposal | `WHY_SLF4J_AND_INJECTION.md` | `OPEN_QUESTIONS.md`, `DESIGN.md` | +| Implementing the next chunk | `OPEN_QUESTIONS.md` | `PLAN.md` (ordering of work), `NATIVE_BRIDGE.md` | + +## Status + +`lib_logging` is wired into the XDK build and ships its module in the distribution. The +public API surface and default impls compile (modulo Ecstasy syntax review — see +`OPEN_QUESTIONS.md`). The runtime side that resolves `@Inject Logger` to a real +`BasicLogger` instance is **not yet wired up**; that is the next step (entry 9 in +`OPEN_QUESTIONS.md`). + +The unit tests under `lib_logging/src/test/x/LoggingTest/` exercise the API by +constructing `BasicLogger` instances directly against an in-memory `ListLogSink`. They +do not depend on the runtime injection plumbing. + +## Reference + +The original SLF4J provider/architecture writeup that shaped much of this design is in +[`slf4j_full_guide.md`](../../slf4j_full_guide.md) at the repo root. It is a Java-side +guide to building a custom SLF4J binding and is the source of the architectural +template (`MyLangLogger` → `RuntimeLogSink` → backend) we adapted into +`Logger` → `LogSink` → backend. diff --git a/doc/logging/SLF4J_PARITY.md b/doc/logging/SLF4J_PARITY.md new file mode 100644 index 0000000000..127163f607 --- /dev/null +++ b/doc/logging/SLF4J_PARITY.md @@ -0,0 +1,117 @@ +# SLF4J 2.x → lib_logging mapping + +This document inventories every SLF4J 2.x type and major method that user code is likely +to touch, and shows the equivalent in `lib_logging`. The intent is that an SLF4J user can +scan this once and know everything they need. + +## Acquiring a logger + +| SLF4J 2.x (Java) | lib_logging (Ecstasy) | +|---|---| +| `LoggerFactory.getLogger(MyClass.class)` | `LoggerFactory.getLogger(MyClass)` | +| `LoggerFactory.getLogger("com.example.foo")` | `LoggerFactory.getLogger("com.example.foo")` | +| (no equivalent) | `@Inject Logger logger;` | +| (no equivalent) | `@Inject("com.example.foo") Logger logger;` | + +## `Logger` core methods + +SLF4J's `Logger` defines five families (one per level) each with ~10 overloads. We collapse +each family to one canonical signature using Ecstasy default arguments, plus the fluent +builder for unusual shapes. + +| SLF4J 2.x (Java) | lib_logging (Ecstasy) | +|---|---| +| `void info(String msg)` | `info(message)` | +| `void info(String format, Object arg)` | `info(message, [arg])` | +| `void info(String format, Object arg1, Object arg2)` | `info(message, [arg1, arg2])` | +| `void info(String format, Object... args)` | `info(message, args)` | +| `void info(String msg, Throwable t)` | `info(message, cause=t)` | +| `void info(Marker marker, String msg)` | `info(message, marker=m)` | +| `void info(Marker marker, String format, Object... args)` | `info(message, args, marker=m)` | +| `void info(Marker marker, String msg, Throwable t)` | `info(message, cause=t, marker=m)` | +| `boolean isInfoEnabled()` | `infoEnabled` (property) | +| `boolean isInfoEnabled(Marker marker)` | `isEnabled(Info, marker=m)` | +| `LoggingEventBuilder atInfo()` | `atInfo()` | +| `LoggingEventBuilder atLevel(Level level)` | `atLevel(level)` | +| `String getName()` | `name` (property) | + +Same applies to `trace`, `debug`, `warn`, `error`. + +## `Level` enum + +| SLF4J `org.slf4j.event.Level` | lib_logging `Level` | +|---|---| +| `TRACE` | `Trace` | +| `DEBUG` | `Debug` | +| `INFO` | `Info` | +| `WARN` | `Warn` | +| `ERROR` | `Error` | +| (n/a — sink-side only) | `Off` | + +## `Marker` + +| SLF4J 2.x (Java) | lib_logging (Ecstasy) | +|---|---| +| `MarkerFactory.getMarker("AUDIT")` | `MarkerFactory.getMarker("AUDIT")` | +| `MarkerFactory.getDetachedMarker("X")` | `MarkerFactory.getDetachedMarker("X")` | +| `marker.add(child)`, `marker.remove(child)` | `marker.add(child)`, `marker.remove(child)` | +| `marker.hasReferences()` | `marker.hasReferences` | +| `marker.iterator()` | `marker.references` | +| `marker.contains(other)` | `marker.contains(other)` | +| `marker.contains("NAME")` | `marker.containsName("NAME")` | +| `marker.getName()` | `marker.name` | + +## `MDC` + +| SLF4J 2.x (Java) | lib_logging (Ecstasy) | +|---|---| +| `MDC.put("k", "v")` | `mdc.put("k", "v")` | +| `MDC.get("k")` | `mdc.get("k")` | +| `MDC.remove("k")` | `mdc.remove("k")` | +| `MDC.clear()` | `mdc.clear()` | +| `MDC.getCopyOfContextMap()` | `mdc.copyOfContextMap` | + +In Ecstasy the MDC is acquired by injection (`@Inject MDC mdc;`) rather than via a static +class. The semantics — per-fiber/thread scratchpad readable by sinks — are the same. + +## `LoggingEventBuilder` (SLF4J 2.x fluent API) + +| SLF4J 2.x (Java) | lib_logging (Ecstasy) | +|---|---| +| `b.setMessage("...")` | `b.setMessage("...")` | +| `b.addArgument(x)` | `b.addArgument(x)` | +| `b.addMarker(m)` | `b.addMarker(m)` | +| `b.setCause(e)` | `b.setCause(e)` | +| `b.addKeyValue("k", v)` | `b.addKeyValue("k", v)` | +| `b.log()` | `b.log()` | +| `b.log("msg")` | `b.log("msg")` | +| `b.log("fmt", a)` | `b.log("fmt", a)` | + +## Message format + +SLF4J's `{}` placeholder semantics are reproduced verbatim by `MessageFormatter.format`: + +- Each unescaped `{}` consumes the next argument's `toString()`. +- Excess arguments are dropped; excess placeholders are left literal. +- `\{}` is an escape; the literal `{}` is emitted. +- `\\{}` is an escaped backslash followed by a real placeholder. +- The last argument, if it is an `Exception` and there is no remaining placeholder, is + promoted to the cause. (SLF4J calls this "throwable promotion".) + +## What we don't replicate + +- SLF4J's static `LoggerFactory` returns logger instances unconditionally; ours is a + service that accesses an injected sink. Behaviorally identical for users. +- SLF4J's `EventRecodingLogger` / `SubstituteLoggerFactory` machinery for handling + pre-init log calls — we don't have an init race because injection resolves at scope + entry. +- SLF4J's `Marker.iterator()` is a Java `Iterator`; ours is an Ecstasy + `Iterator`. Identical concept. + +## What we add + +- Native injection (`@Inject Logger logger;`). SLF4J has nothing like this; it relies on + the static factory. Injection makes per-container sink override trivial — see + `OPEN_QUESTIONS.md` for the open piece around per-container overrides. +- `Off` level, useful for sink configuration. SLF4J expresses this through level checks + in `Logger.isEnabledFor(...)`. diff --git a/doc/logging/STRUCTURED_LOGGING.md b/doc/logging/STRUCTURED_LOGGING.md new file mode 100644 index 0000000000..f8eb63680c --- /dev/null +++ b/doc/logging/STRUCTURED_LOGGING.md @@ -0,0 +1,217 @@ +# Structured logging in `lib_logging` + +This document explains how structured logging works in SLF4J 2.x, and how the same idea +maps onto `lib_logging`. "Structured" here means: events that carry typed key/value pairs +in addition to (or instead of) a free-form message, so that downstream consumers (log +aggregators, dashboards, alerting) can query on those fields without regexing the text. + +## How SLF4J 2.x does structured logging + +SLF4J 1.x had no structured-logging story at the API level. You either: + +- abused the message text (`log.info("event=login user={} ip={}", user, ip)`), which works + but every consumer has to parse it back out; or +- depended directly on `logback-classic` and added structured fields through + `org.slf4j.Marker` subclasses or a custom `Encoder`. + +SLF4J 2.x added **first-class key/value pairs** via the fluent `LoggingEventBuilder`. +The relevant types: + +| Type | Role | +|---|---| +| `org.slf4j.spi.LoggingEventBuilder` | Builder returned by `logger.atInfo()` / `atLevel(...)`. Provides `addKeyValue(String, Object)`. | +| `org.slf4j.event.KeyValuePair` | Tiny value type holding `(String key, Object value)`. | +| `org.slf4j.spi.LoggingEventAware` | Marker interface for sinks that want the raw `LoggingEvent` (including KV pairs) instead of just the formatted message. | +| `org.slf4j.event.LoggingEvent.getKeyValuePairs()` | Sink-side accessor. | + +A typical SLF4J 2.x structured log call: + +```java +log.atInfo() + .setMessage("payment processed") + .addKeyValue("paymentId", payment.id()) + .addKeyValue("amount", payment.amount()) + .addKeyValue("currency", payment.currency()) + .addKeyValue("merchantId", merchant.id()) + .log(); +``` + +A Logback encoder that understands KV pairs (e.g. `LogstashEncoder` from the +`logstash-logback-encoder` library) emits this as JSON: + +```json +{ + "@timestamp": "2026-04-29T11:23:45.012Z", + "level": "INFO", + "logger": "com.example.PaymentService", + "message": "payment processed", + "paymentId": "p_123", + "amount": 4200, + "currency": "EUR", + "merchantId": "m_42" +} +``` + +The PatternLayout-based encoders (the default `ch.qos.logback.classic.encoder.PatternLayoutEncoder`) +*don't* surface KV pairs — they only see the formatted message. So in practice, structured +logging in the SLF4J/Logback world means: **caller emits with `addKeyValue`, plus a +KV-aware encoder on the sink side**. + +## Three layers in the SLF4J approach + +It's worth pulling these apart because the same three layers exist in `lib_logging`: + +1. **The call-site API** — how the caller expresses "this event carries these fields". + In SLF4J 2.x: `LoggingEventBuilder.addKeyValue`. KV pairs are typed `Object`, not + `String`, so the caller doesn't have to pre-format. + +2. **The event data model** — how the event carries the fields between the call site + and the sink. In SLF4J: `LoggingEvent.getKeyValuePairs()` returning `List`. + +3. **The sink/encoder** — what actually renders or forwards the structured data. In + SLF4J/Logback: a KV-aware `Encoder` (LogstashEncoder, GelfEncoder, etc.) or a custom + `Appender` that consumes `getKeyValuePairs()` directly. + +Most SLF4J users only see layer 1. Library authors and ops engineers see all three. + +## How `lib_logging` maps onto this + +The same three layers, with the same separation of concerns: + +### Layer 1 — call site + +`LoggingEventBuilder` already has `addKeyValue(String key, Object value)`, mirroring SLF4J: + +```ecstasy +logger.atInfo() + .setMessage("payment processed") + .addKeyValue("paymentId", payment.id) + .addKeyValue("amount", payment.amount) + .addKeyValue("currency", payment.currency) + .addKeyValue("merchantId", merchant.id) + .log(); +``` + +This is the public, stable API — caller code committed to this signature today will not +break when richer sinks arrive. + +### Layer 2 — event data model + +The `LogEvent` const should grow a `keyValues` field analogous to SLF4J's +`getKeyValuePairs()`. The current stub does not have it; this is one of the v0.1 follow-ups +tracked in `OPEN_QUESTIONS.md`. The intended shape: + +```ecstasy +const LogEvent( + String loggerName, + Level level, + String message, + Marker? marker = Null, + Exception? exception = Null, + Object[] arguments = [], + immutable Map keyValues = Map:[], // ← add + immutable Map mdcSnapshot = Map:[], + String threadName = "", + Time timestamp, + ); +``` + +The `BasicEventBuilder` accumulates KV pairs in a local map (currently a TODO) and passes +them through when `log()` materializes the event. + +### Layer 3 — sink + +`LogSink` already gets the whole `LogEvent`, so a sink that wants to render KV pairs +just reads `event.keyValues`. The default `ConsoleLogSink` is dumb on purpose: it would +append KV pairs as `key=value key=value` after the message, in the same shape Logback's +default `PatternLayout` uses for MDC. + +A KV-aware structured sink — say `JsonLineLogSink` — would render every event as a +single JSON object per line: + +```json +{"timestamp":"2026-04-29T11:23:45.012Z","level":"INFO","logger":"com.example.PaymentService","message":"payment processed","paymentId":"p_123","amount":4200,"currency":"EUR","merchantId":"m_42","mdc":{"requestId":"r_abc"}} +``` + +That's a separate file (`JsonLineLogSink.x`) implementing `LogSink`, and the runtime +chooses whether it's the active sink. Caller code is unchanged. + +### Sketch: a `JsonLineLogSink` + +```ecstasy +service JsonLineLogSink + implements LogSink { + + @Inject Console console; + public/private Level rootLevel = Info; + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= rootLevel.severity; + } + + @Override + void log(LogEvent event) { + StringBuffer json = new StringBuffer(); + json.append('{'); + appendField(json, "timestamp", event.timestamp.toString()); + appendField(json, "level", event.level.name); + appendField(json, "logger", event.loggerName); + appendField(json, "message", event.message); + if (Marker m ?= event.marker) { + appendField(json, "marker", m.name); + } + for ((String k, Object v) : event.keyValues) { + appendField(json, k, v); + } + if (!event.mdcSnapshot.empty) { + appendObject(json, "mdc", event.mdcSnapshot); + } + if (Exception e ?= event.exception) { + appendField(json, "exception", e.toString()); + } + json.append('}'); + console.print(json.toString()); + } + + // ... helper methods elide JSON-escape detail; in production, defer to lib_json. +} +``` + +Callers don't change. The sink is the only thing that knows the wire format is JSON. + +## What about `MDC` vs key/value pairs? + +SLF4J has _two_ structured-data channels: + +- **MDC** — process-/fiber-local, set by surrounding code, carried on every event in the + scope. Used for context that doesn't change per call: request ID, tenant, user. +- **Key/value pairs** — per-call, attached to one specific event. Used for facts about + *this* specific log entry: payment ID, amount, target. + +`lib_logging` keeps the same split. `MDC` is its own service; `LoggingEventBuilder.addKeyValue` +is per-event. A structured sink should render both — typically MDC under an `"mdc"` sub-object +and KV pairs at the top level, matching what `LogstashEncoder` does. + +## Migration story for SLF4J users adopting `lib_logging` + +The smallest possible change to a structured-logging-heavy codebase moving from SLF4J to +`lib_logging` is _none_: `atInfo()`/`addKeyValue`/`log()` is identical. Only the import +changes, and the LogSink choice on the deployment side. + +The bigger story — encoder/appender wiring, log aggregator integration — is handled by +the structured sink (`JsonLineLogSink` in the simple case, a future Logback-bridge sink +in the complex case). See `LOGBACK_INTEGRATION.md`. + +## What to build first vs. later + +For v0.1, the right order is: + +1. Add `keyValues: Map` to `LogEvent` and wire it through + `BasicEventBuilder.addKeyValue`. +2. Make `ConsoleLogSink` append KV pairs in `key=value` form after the message. +3. Add a `JsonLineLogSink` that renders one JSON object per event (uses `lib_json`). + +Everything beyond that — typed value formatting, schema validation, OpenTelemetry +integration, log-correlation IDs propagated across services — is sink-side. The API +is already shaped to support it without changes. diff --git a/doc/logging/WHY_SLF4J_AND_INJECTION.md b/doc/logging/WHY_SLF4J_AND_INJECTION.md new file mode 100644 index 0000000000..41b91a4570 --- /dev/null +++ b/doc/logging/WHY_SLF4J_AND_INJECTION.md @@ -0,0 +1,323 @@ +# Why model `lib_logging` on SLF4J and on Ecstasy injection + +This is the rationale doc. It exists to make the case that the two big shape-defining +choices in `lib_logging` — _model the API on SLF4J_, _acquire loggers via `@Inject`_ — +are not arbitrary preferences but the only choices that combine into a logging library +worth shipping. Everything else has been tried, in many languages, and falls short in +specific, predictable ways. + +## TL;DR + +- **Modeling on SLF4J means we inherit thirty years of hard-won design.** SLF4J is the + one logging API in the Java ecosystem that survived the wars (log4j 1.x → log4j 2 → + Logback → JUL → JBoss Logging → ...). It survived because it got the API↔impl boundary + right. Copying that boundary copies the wins. +- **Modeling injection on `@Inject` means user code never depends on a backend.** This is + the thing static `LoggerFactory.getLogger` *almost* gives you in Java but never quite + does — there is always a static binding choice somewhere on the classpath. In Ecstasy + the runtime container provides the binding. There is no classpath race. +- **The combination is greater than the sum of the parts.** Ecstasy gives us + per-container injection; SLF4J gives us a stable user-facing API. Together: a logging + library where the host application controls every embedded module's logging without + any embedded module having to know. + +The rest of this document expands each of those. + +--- + +## Part 1 — Why SLF4J is the right model + +### 1.1 The API/impl boundary is the only thing that has ever held up + +Logging in Java has churned through at least seven serious facades and frameworks: +`System.err.println`, log4j 1.x, JUL (`java.util.logging`), Apache Commons Logging, +JBoss Logging, log4j 2, SLF4J + Logback. Each replacement had a moment of "this is the +new way". Most of them are now legacy. + +The thing that has stuck is the **SLF4J API**. Logback replaced log4j as the canonical +backend. log4j 2 came along. Then `slf4j-jul-impl`, `log4j-slf4j-impl`, etc. — bridges +in every direction. None of this churn touched user code. The reason is that SLF4J got +exactly one thing right that Commons Logging got wrong and JUL got wrong: **the +caller-facing API is one stable shape, and the binding is a separate jar selected at +deploy time.** Caller code does not import the binding; it cannot, by construction. + +This is the decision we must inherit, because the decision pays for itself for decades. +A new language that ships a logging library that mixes API and binding (the way JUL +does, the way Python's stdlib `logging` does) has just signed up for the same churn. + +### 1.2 SLF4J's specific design choices, why they're each load-bearing + +The signature shapes look small but they encode hard-won lessons. Walking through them: + +- **`{}` placeholder formatting**, *not* `String.format`. `String.format` is expensive + even when the level is disabled. The whole reason SLF4J exists is that log4j 1.x users + paid the cost of `"some msg " + obj.toString()` even when DEBUG was off. Deferred + formatting via `{}` placeholders + arrays of arguments lets the implementation skip + the work entirely when `isDebugEnabled()` is false. This is the original SLF4J + motivation; it is still load-bearing. + +- **Throwable promotion**. The trailing `Throwable` is *not* a substitution argument; it + is the cause. This rule looks small but it eliminates an entire class of footgun + ("why is my stack trace just `java.io.IOException@1c2d4f` in the message?"). SLF4J + users learn this rule once. + +- **Level checks as first-class methods.** `if (log.isInfoEnabled())` is the standard + Java idiom for "this log call has an expensive argument I want to elide". Frameworks + that lack this method force callers to either pay the formatting cost or write + unreadable wrapper code. + +- **Markers as a structured tag.** Markers are how you say "this event belongs to the + AUDIT category" without resorting to either a separate logger name or substring + matching on messages. Backend filters can route on markers without grep. + +- **MDC for context propagation.** MDC is _the_ idiom for request-scoped context. + Every observable Java service stack uses it. If you want a log line tagged with + `requestId` without your business code having to thread it through, you set it once + in the request handler and the encoder picks it up. + +- **The fluent event builder (SLF4J 2.x).** Eight years after SLF4J 1.x shipped, the + community decided the explosion of overload signatures (`info(String)`, `info(String, + Object)`, `info(String, Object, Object)`, `info(Marker, ...)`, ...) was a mistake. The + fluent builder is the answer: one builder type with `addArgument`, `addMarker`, + `setCause`, `addKeyValue`, then `log()`. SLF4J kept the legacy overloads for + backwards compatibility, but new code is moving to the builder. We get to skip the + legacy and ship the builder as a first-class option from day one. + +Every one of these is in `lib_logging`. None of them is novel; all of them are +load-bearing. Throw any one out and we have a worse logging library. + +### 1.3 The alternatives, why none of them is good enough + +| Model | Why we don't copy it | +|---|---| +| **`java.util.logging` (JUL).** The "ship something with the language" approach. | Levels are weird (`FINE`/`FINER`/`FINEST` instead of `DEBUG`/`TRACE`); MDC support was added late and is awkward; configuration is a flat properties file with no programmatic equivalent; performance is poor on hot paths. JUL is the cautionary tale — it's what you get when "we'll just put a logger in the language" goes uncritically. | +| **log4j 1.x.** Once the dominant model. | Eager string concat in caller code (no `{}` deferred formatting), `Logger.getLogger` static factory races, no MDC at the API level. SLF4J was created specifically because of these. | +| **log4j 2.** Strong API, strong impl, but bundled. | The API and the impl are tightly coupled. You cannot replace the impl without giving up the API. We need the impl-as-plugin property; log4j 2 doesn't preserve it cleanly. | +| **Apache Commons Logging.** Tried to be a facade. | Famously broken classloader behaviour ("the JCL nightmare"). The dynamic discovery model didn't survive the move to OSGi/modular classloaders. SLF4J's static binding fixed exactly this. | +| **Python `logging`.** Stdlib. | Hierarchical logger configuration is good; the API is verbose; no fluent builder; no markers; bound to a global root by default. Decent for Python culture; doesn't fit Ecstasy's injection-first culture. | +| **Go `log/slog` (1.21+).** Modern. | Reasonable shape. `slog` is _newer_ than SLF4J 2.x and arrived at a similar place: structured KV, levels, handlers. We could equally well copy `slog`. We don't, because (a) far fewer engineers know `slog` than know SLF4J, and (b) `slog`'s ergonomics around groups and context don't add anything an MDC + KV combo doesn't already give us. | +| **Roll our own.** Greenfield. | The space is well-trodden. Every novel decision is a decision we'd have to defend forever. The only good reason to depart from SLF4J is to fit Ecstasy's injection model — which is what we're doing for acquisition, but not for the API surface itself. | + +The strongest competing argument is "Go `slog` is a cleaner design and Ecstasy is a new +language; why anchor to a Java idiom?" The answer: developer onboarding. We will pay the +cost of every developer learning a new logging API forever. We don't need to. SLF4J's +shape is in the muscle memory of probably the largest population of working backend +engineers in the world. Free familiarity is not nothing. + +### 1.4 What the boundary buys us _specifically_ + +Concretely, here's what splitting `Logger` from `LogSink` and copying the SLF4J API +shape gets us: + +- **A library author writes `@Inject Logger logger; logger.info(...)` and is done.** + They do not pick a backend, they do not have a config file, they cannot break + downstream consumers by their choice. A library that does logging is a library that + does logging *politely*. +- **An application author can swap `ConsoleLogSink` for `LogbackLogSink` (future) for + `NativeSlf4jLogSink` (future) without recompiling any library.** Behind a stable + injection point. +- **Test authors get `MemoryLogSink` for free.** They wire it up once and assert on + `events`. No global state, no `LogManager.getLogManager().reset()`, no flaky tests. +- **Future structured-logging consumers (log aggregators, dashboards) get a stable event + shape.** We add a `keyValues` map to `LogEvent` once; every sink that wants to render + it does, every sink that doesn't ignores it. No format wars. + +These are not theoretical. Every Java shop that runs Logback + LogstashEncoder for +production and SimpleLogger for tests is benefiting from exactly this layering today. +We get the same property out of the box. + +--- + +## Part 2 — Why injection (`@Inject Logger`) is the right acquisition mechanism + +### 2.1 What `LoggerFactory.getLogger` actually is in Java + +The Java idiom is: + +```java +private static final Logger log = LoggerFactory.getLogger(MyClass.class); +``` + +This is a static factory call resolved against whatever SLF4J binding is on the +classpath at startup. In a single-application JVM with one classpath, it works. In +anything more complex it gets ugly: + +- **Two bindings on the classpath.** Random one wins. SLF4J prints a warning at + startup, but nothing else. +- **Library shading.** Two libraries each shade their own SLF4J binding into their jars. + Classloader lottery decides which wins. +- **Multi-tenant containers.** Different tenants want different log routing. Java has no + natural way to express this; you end up plumbing tenant-scoped loggers manually or + using `LogContext`-style hacks. +- **Tests that mutate global state.** Setting up `ListAppender` in a test means + registering it on a logger that is global to the JVM, with cleanup discipline. + +These are paper cuts in Java. They are accepted because the language has no better +option. **Ecstasy does have a better option.** The injection model is the better option. + +### 2.2 What `@Inject Logger` gives us that static factories cannot + +Ecstasy's `@Inject` is resolved per-container by the runtime, with a `(Type, name)` +key. Concretely, this means: + +- **The host application's container chooses the sink.** A test container injects + `MemoryLogSink`. A production container injects a configuration-driven sink. The + embedded library has no opinion. Static `LoggerFactory.getLogger` cannot do this + because the choice is made at JVM startup by classpath discovery. + +- **Per-container override is a one-line change.** Want module X to log to a different + destination than module Y? Provide a different Injector for module X's container. + No bridges, no `MarkerFilter` hacks, no per-tenant `LogContext`. + +- **No classpath surprises.** There is no "two bindings, who wins?" question because + there is no classpath-level binding. The binding is a function in the suppliers map. + +- **Testing is trivial.** A test sets up a child container with a `MemoryLogSink`, + runs the system under test, asserts on captured events. No global mutation, no + per-test `BeforeEach` cleanup of root logger appenders, no race when tests run in + parallel. + +- **Library authors don't depend on a configuration model.** A Java library that wants + to log _to a specific destination_ in some scenarios usually adds a `setLogger(Logger)` + method. With injection that's no longer needed; the *consumer* of the library is the + one who decides what the library's `Logger` resolves to. + +### 2.3 The `Console` precedent + +Ecstasy already does this for `Console`. `@Inject Console console` is a stable contract; +the runtime swaps `TerminalConsole`, headless test consoles, captured-output consoles, +embedded-host-controlled consoles, all transparently. Logging is exactly the same +problem with the same shape: it's an output channel the platform should control on the +caller's behalf. Treating loggers like consoles is *the obvious choice once you see it*. + +The alternative — "loggers are special, they need a static factory because that's what +SLF4J does" — gives up the property that motivated `Console` injection in the first +place. We would not accept that for `Console`; we should not accept it for `Logger`. + +### 2.4 What about the `LoggerFactory` static escape hatch? + +`lib_logging` ships `LoggerFactory` for code that genuinely cannot use injection (e.g., +static initializers, anonymous closures with no injection scope). The escape hatch is +itself a service that consults an injected default sink — the same plumbing as +`@Inject Logger`, just exposed via a classmethod-like accessor. + +This means even the escape hatch respects per-container overrides. Static is not the +same as global. + +--- + +## Part 3 — Why the combination is greater than the sum of the parts + +Now put the two together. + +### 3.1 The library author's experience + +Writes: +```ecstasy +@Inject Logger logger; +logger.info("processed {} records", count); +``` + +Reads like SLF4J. Costs nothing to learn for an SLF4J veteran. Imports nothing +backend-specific. Cannot accidentally pin a binding. Cannot leak a binding choice into +downstream consumers. Cannot affect behaviour in unrelated modules. Will keep working if +the runtime swaps the sink, the binding, the config file, the encoder, anything. + +This is what every facade has aspired to, and almost-but-not-quite achieved. + +### 3.2 The host application's experience + +Wants production-grade logging? Provide a `LogbackLogSink` (future module). Provide it +*to the container* and every library inside the container picks it up. No +"add slf4j-logback to the classpath" ceremony, no version-skew warnings, no shading +games. The host owns the injector; the host owns the routing. + +Wants logs disabled for some embedded module? Override the injection for that module's +sub-container. There is no Java equivalent that doesn't involve `MarkerFilter` hacks. + +Wants a custom JSON wire format? Write a `JsonLineLogSink`, register it. Forty-plus +embedded libraries in the application start emitting JSON without one of them having +done anything. + +### 3.3 The ops engineer's experience + +Same story as the host: the routing decision is exactly one place — the resource +provider in the injector. There is no second place. There is no `logback.xml` somewhere +in a transitive dependency overriding the choice. + +### 3.4 The test author's experience + +Wires `MemoryLogSink` into the test container, runs the system under test, asserts on +`sink.events`. Test isolation is automatic because each test container has its own +suppliers map. No global root logger to reset. + +This last property is, frankly, hard to overstate. Anyone who has chased a flaky CI test +caused by a `ListAppender` not being detached at the end of a previous test knows what +the alternative looks like. + +--- + +## Part 4 — Anticipating objections + +**"SLF4J is showing its age. Should we copy something newer?"** + +The shape of the API is exactly what is _not_ aging. The features added in SLF4J 2.x +(KV pairs, fluent builder) are themselves modern. There is no working logging library in +mainstream use whose user-facing surface is meaningfully different *and* better. `slog` +in Go is closest, and the user-facing differences (groups, attribute encoding) are not +improvements so much as different trade-offs. None of them justify giving up SLF4J's +familiarity. + +**"Why not let users pick their style — slog-like *or* SLF4J-like?"** + +Two APIs is worse than one. Ecstasy is a new language; the cost of forcing one idiom is +small, and the cost of supporting two forever is large. + +**"Why not lean on `@Inject Console console` and skip the whole thing?"** + +`println` doesn't have levels. It doesn't have markers. It doesn't have MDC. It doesn't +have parameterized messages with deferred formatting. It doesn't have the API/impl +boundary. Building any of those on top of `Console` ad hoc, in every library that needs +them, is exactly the world before SLF4J — and we know how that turns out. + +**"What about the runtime cost of the indirection?"** + +The level check (`sink.isEnabled(name, level, marker)`) is the fast path. When the +level is disabled, no formatting work happens, no MDC snapshot is taken, no event is +constructed. This is the same fast-path SLF4J has and it is well-trodden. + +**"Could we change our minds later?"** + +Most of the API surface is in the `Logger` interface and a few small types +(`Level`, `Marker`, `MDC`, `LogEvent`, `LoggingEventBuilder`). Anything below `LogSink` +is replaceable today; anything above is replaceable only by breaking caller code. +Choosing the SLF4J shape now is the choice we want locked in. It's the choice with the +shortest list of failure modes. + +--- + +## Summary + +We're not picking SLF4J because we like Java. We're picking SLF4J because: + +1. The API/impl split it pioneered is the only logging architecture that has survived + thirty years of churn intact. +2. The specific signatures it standardized (`{}` deferred formatting, throwable + promotion, level checks, markers, MDC, fluent KV) each solve a real problem nobody + wants to re-solve. +3. The audience we are onboarding to Ecstasy already speaks this dialect. + +We're not picking `@Inject Logger` because we like dependency injection. We're picking +it because: + +1. Static `LoggerFactory.getLogger` has known failure modes (classpath races, shading + conflicts, global state in tests) that Ecstasy's container model just doesn't + exhibit. +2. The same property already pays for itself with `@Inject Console`. Logging is + strictly the same shape as console output and benefits from exactly the same + mechanism. +3. The combination — SLF4J's API surface plus per-container injection — is a logging + library that no Java framework can quite ship, because Java doesn't have + per-container injection at the language level. Ecstasy does. We should use it. + +This is what "instantly familiar to all SLF4J users *and* better" looks like. diff --git a/doc/logging/XDK_ALIGNMENT.md b/doc/logging/XDK_ALIGNMENT.md new file mode 100644 index 0000000000..5aaf5aa2bd --- /dev/null +++ b/doc/logging/XDK_ALIGNMENT.md @@ -0,0 +1,202 @@ +# XDK lib alignment — making `lib_logging` a first-class citizen + +This document audits `lib_logging` against the conventions used by the other shipped XDK +libs (`lib_ecstasy`, `lib_cli`, `lib_crypto`, `lib_json`, `lib_jsondb`, `lib_net`, +`lib_oodb`, `lib_sec`, `lib_web`, `lib_webauth`, `lib_xenia`, `lib_xunit`) and against +how they are actually used in `~/src/platform` and `~/src/examples`. Every deviation has +to be a deliberate choice. + +## The XDK conventions, observed + +### 1. Injectables are interface types acquired by `@Inject Type ref;` + +Across the XDK and downstream code, every injected resource is an interface that the +runtime resolves to an implementation: + +| Resource type | Where defined | Used like | +|---|---|---| +| `Console` | `lib_ecstasy/src/main/x/ecstasy/io/Console.x` | `@Inject Console console;` | +| `Clock` | `lib_ecstasy/src/main/x/ecstasy/temporal/Clock.x` | `@Inject Clock clock;` | +| `Random` | `lib_ecstasy/src/main/x/ecstasy/numbers/Random.x` | `@Inject Random random;` | +| `Directory` | `lib_ecstasy/src/main/x/ecstasy/fs/Directory.x` | `@Inject Directory curDir;` | +| `FileSystem` | `lib_ecstasy/src/main/x/ecstasy/fs/FileSystem.x` | `@Inject FileSystem fs;` | +| `ModuleRepository`| `lib_ecstasy/.../mgmt/ModuleRepository.x` | `@Inject ModuleRepository repository;` | +| `Compiler` | `lib_ecstasy/.../mgmt/Compiler.x` | `@Inject Compiler compiler;` | +| `Algorithms` | `lib_crypto/.../crypto/Algorithms.x` | `@Inject Algorithms algorithms;` | +| `CertificateManager` | `lib_crypto/.../crypto/CertificateManager.x` | `@Inject CertificateManager manager;` | +| `Authenticator?` | `lib_webauth/...` | `@Inject Authenticator? authenticator;` | + +The pattern is so consistent it is the language's idiom. **`Logger` slots into exactly +this list.** No code in the XDK uses a static factory to acquire a runtime resource; +that's the SLF4J/Java pattern that `lib_logging` deliberately turns into injection. + +### 2. Named injection where multiple instances are reasonable + +Where there is more than one of something, the resource name disambiguates: + +```ecstasy +@Inject Directory curDir; +@Inject Directory testOutput; +@Inject Directory testOutputRoot; +@Inject Directory tmpDir; +@Inject("homeDir") Directory home; // from BasicResourceProvider +``` + +`lib_logging` does the same: `@Inject Logger logger;` for the default, optional +`@Inject("com.example.foo") Logger logger;` for a named one. + +### 3. Defaults via the `Inject` annotation's `resourceName` + +`lib_ecstasy/src/main/x/ecstasy/annotations/Inject.x` defines the annotation as +`Inject(String? resourceName = Null, Options opts = Null)`. The `resourceName` +is the dispatch key; null means "default for this type". Every XDK lib follows this. + +`lib_logging` follows it too: `@Inject Logger` resolves the default, `@Inject("foo") +Logger` names the logger. Same shape as `Directory`. + +### 4. The four canonical interfaces for shareable values + +Other XDK libs that ship value types reach for one or more of these mixins consistently: + +- **`Freezable`** (`lib_ecstasy/src/main/x/ecstasy/Freezable.x`) — for anything that may + cross a service boundary. Sinks are typically services; anything they receive must be + freezable. Examples: `Path`, `URI`, `Tuple`, every `const`. +- **`Stringable`** (`lib_ecstasy/src/main/x/ecstasy/text/Stringable.x`) — for anything + routinely formatted into log lines, JSON, or CLI output. Lets the renderer pre-size + buffers via `estimateStringLength()`. +- **`Hashable`** (`lib_ecstasy/src/main/x/ecstasy/collections/Hashable.x`) — for anything + that may end up as a `Map` or `Set` key. +- **`Orderable`** (`lib_ecstasy/src/main/x/ecstasy/Orderable.x`) — for anything that has + a natural ordering. + +`lib_ecstasy/.../Const.x:149-152` defines `Const` as `extends Hashable, Orderable, +Freezable, Stringable` — all four. So every `const` gets them automatically. + +### 5. Const for value types, service for stateful actors, class otherwise + +- **`const`** for immutable value records: `Path`, `URI`, `Time`, `Duration`, `Range`. +- **`service`** for stateful, thread-safe shared resources: `TerminalConsole`, + `Clock`, `Random`, sinks that hold writers / queues / counters. +- **`class`** for everything else: small mutable helpers, builders. + +### 6. Module declaration shape + +Every lib_* declares one module file at `src/main/x/.x` with a brief module +doc-comment, naming the package as `.xtclang.org`. Subordinate types live under +`src/main/x//.x`. Tests live under `src/test/x/.x` plus +`src/test/x//.x`. + +### 7. Build script shape + +Every lib_* has a tiny `build.gradle.kts`: + +```kotlin +plugins { + alias(libs.plugins.xtc) +} + +dependencies { + xdkJavaTools(libs.javatools) + xtcModule(libs.xdk.ecstasy) + // optional test deps: + xtcModuleTest(libs.javatools.bridge) + xtcModuleTest(libs.xdk.xunit.engine) +} +``` + +`lib_logging`'s build script is identical. + +## How `~/src/platform` and `~/src/examples` use these conventions + +### `~/src/platform` patterns + +- `kernel.x` injects `Console`, `Clock`, `ModuleRepository`, `CertificateManager`, + `Configuration` — all type-keyed, no resource names except where explicitly needed. +- `host/HostManager.x` injects `Directory testOutput;` — a named directory. +- `auth/OAuthEndpoint.x` and `auth/OAuthProvider.x` use `@Inject Console console;` + followed by ad-hoc `console.print($"Info : ...")` / `console.print($"Error: ...")` + patterns — exactly the surface `lib_logging` is meant to absorb. + +### `~/src/examples` patterns + +- `welcome.x` uses `@Inject Console console;` for genuine user-facing output. +- `CardGame/cardGame.x` and friends inject `Console` for game state messages — these + remain `Console` after lib_logging adoption (they are user output, not log events). +- `chess-game/...` similarly mixes user output and would benefit from a clean split. + +The clean split between "user output" (`Console`) and "diagnostic event" (`Logger`) +is exactly the property `lib_logging` introduces. Both repos already inject `Console` +the right way; they just lack a `Logger` to round out the injection surface. + +## Where `lib_logging` already follows the conventions + +| Convention | `lib_logging` | +|---|---| +| Injectable interface type | `Logger`, `LogSink`, `MarkerFactory`, `MDC`, `LoggerFactory` — all interfaces or services with `@Inject`-able shape. | +| Named injection for named instances | `@Inject("com.example") Logger logger;` | +| `Const` for immutable records | `LogEvent` | +| `service` for stateful actors | `ConsoleLogSink`, `MarkerFactory`, `MDC`, `LoggerFactory` | +| `class` for value-but-mutable | `BasicLogger`, `BasicEventBuilder`, `BasicMarker`, `MemoryLogSink`, `NoopLogSink` | +| Module file layout | `lib_logging/src/main/x/logging.x` declaring `module logging.xtclang.org` | +| Subordinate types under same dir | `lib_logging/src/main/x/logging/.x` | +| Build script shape | matches `lib_cli` / `lib_json` exactly | +| Test layout | `lib_logging/src/test/x/LoggingTest.x` + `LoggingTest/.x` | +| `Stringable` / `Hashable` / `Freezable` on shareable types | `Marker` extends all three | + +## Where `lib_logging` deliberately *does* deviate, and why + +- **`Marker` is an interface, not a const.** SLF4J's contract requires markers to be + mutable (`add(child)` / `remove(child)`). A `const` cannot be mutable. We keep + `Marker` as an interface, implement `Freezable` for boundary crossing, and let + `BasicMarker` enforce the SLF4J contract on the unfrozen path. + +- **`LogSink` does not extend `Service`.** Most `LogSink` impls *are* services, but the + `LogSink` interface itself doesn't require it. Reason: trivial sinks (`NoopLogSink`) + don't need to be services and shouldn't pay for the per-fiber isolation. This is + consistent with `Stringable` and `Hashable` — XDK interfaces leave the implementation + policy to the implementer rather than forcing one shape. + +- **`Logger` does not extend `Stringable` or `Hashable`.** Loggers are not values; they + are facades. You wouldn't put a `Logger` in a `Map` or render it into a log line. + Compare `Console` — also a façade interface, also not Stringable/Hashable. + +- **`Level` is an enum, not a const.** Enums in Ecstasy already get `Hashable + + Orderable + Freezable + Stringable + Comparable` automatically (via the same `Const` + base they inherit), so there is no extra work to do — but the design choice is + noted because it's the same shape as `lib_ecstasy/.../io/SeekDirection.x` and other + XDK enums. + +## Concrete improvements made to `lib_logging` for XDK alignment + +Pre-alignment, `Marker` was a plain interface and `LogEvent` carried a `String? +markerName` because the unfreezable Marker couldn't ride on a `const`. Post-alignment: + +- `Marker extends Freezable, Stringable, Hashable`. +- `BasicMarker` implements `freeze` (deep-freezes children, makes self immutable) and + acquires `Stringable.appendTo` / `estimateStringLength` for free from `Marker`'s + default methods. +- `LogEvent.marker` carries the full `Marker?` reference again. Sinks doing + `containsName` filtering or `marker.references` traversal work end-to-end without + giving up the const safety. +- The mutable `Object[] arguments` from the fluent builder is converted to + `Constant`-mode (frozen) before being handed to the sink — same boundary-crossing + story Ecstasy applies to every other shareable value type. + +Together: every value that may end up on a `LogEvent` honours the XDK's `Freezable` +contract, every type that wants to be a map key honours `Hashable`, every type that +wants to be efficiently rendered honours `Stringable`. There is no part of +`lib_logging` that an experienced XDK engineer would point at and say "that's not how +we do things." + +## Summary + +`lib_logging` is now structurally indistinguishable from the other shipped XDK libs: + +- Same module / file / build conventions. +- Same injection idiom. +- Same value-type interfaces (`Freezable`, `Stringable`, `Hashable`) where appropriate. +- Same const / service / class split. + +The only thing missing for full first-class status is the runtime-side resource +registration (`@Inject Logger` actually resolving), which is the next chunk of work +described in `OPEN_QUESTIONS.md` items 1, 2, and 9. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79dd21e570..f88d701695 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,6 +137,7 @@ xdk-crypto = { group = "org.xtclang", name = "lib-crypto" } xdk-ecstasy = { group = "org.xtclang", name = "lib-ecstasy" } xdk-json = { group = "org.xtclang", name = "lib-json" } xdk-jsondb = { group = "org.xtclang", name = "lib-jsondb" } +xdk-logging = { group = "org.xtclang", name = "lib-logging" } xdk-net = { group = "org.xtclang", name = "lib-net" } xdk-oodb = { group = "org.xtclang", name = "lib-oodb" } xdk-sec = { group = "org.xtclang", name = "lib-sec" } diff --git a/lib_logging/README.md b/lib_logging/README.md new file mode 100644 index 0000000000..1f1f793a34 --- /dev/null +++ b/lib_logging/README.md @@ -0,0 +1,38 @@ +# lib_logging + +Experimental SLF4J-shaped logging library for Ecstasy. + +> **Status:** Stub / research branch. The API is intended to be the long-lived shape; +> implementations and the runtime injection plumbing are not wired up yet. See the design +> docs under [`doc/logging/`](../doc/logging) at the repo root. + +## What this is + +A logging library for Ecstasy that is **instantly familiar** to anyone who has used SLF4J 2.x +in Java: named loggers, levels, parameterized messages with `{}`, exception attachment, +markers, MDC, the SLF4J 2.x fluent event builder. Acquired by injection: + +```ecstasy +@Inject Logger logger; +logger.info("processed {} records in {}ms", count, elapsed); +``` + +The default sink writes to the platform `Console`. The `LogSink` SPI is the API↔impl +boundary — drop in a richer sink (file, network, JSON, logback-style configuration tree) +without touching caller code. + +## Documentation + +All design docs live at the repo root under [`doc/logging/`](../doc/logging): + +- [`PLAN.md`](../doc/logging/PLAN.md) — master plan, scope, ordering of work +- [`DESIGN.md`](../doc/logging/DESIGN.md) — architecture, module layout, API↔impl boundary +- [`SLF4J_PARITY.md`](../doc/logging/SLF4J_PARITY.md) — every SLF4J 2.x type and method, mapped +- [`ECSTASY_VS_JAVA_EXAMPLES.md`](../doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md) — Java SLF4J + example, then the same thing in Ecstasy, for every API +- [`CUSTOM_SINKS.md`](../doc/logging/CUSTOM_SINKS.md) — guide to writing your own sink +- [`LOGBACK_INTEGRATION.md`](../doc/logging/LOGBACK_INTEGRATION.md) — how a future + logback-style configuration-driven backend would fit +- [`NATIVE_BRIDGE.md`](../doc/logging/NATIVE_BRIDGE.md) — could we plug real Java logging + libraries in via native code? Investigation and recommendation +- [`OPEN_QUESTIONS.md`](../doc/logging/OPEN_QUESTIONS.md) — things still to decide diff --git a/lib_logging/build.gradle.kts b/lib_logging/build.gradle.kts new file mode 100644 index 0000000000..09b9e8bdee --- /dev/null +++ b/lib_logging/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.xtc) +} + +dependencies { + xdkJavaTools(libs.javatools) + xtcModule(libs.xdk.ecstasy) + xtcModuleTest(libs.javatools.bridge) + xtcModuleTest(libs.xdk.xunit.engine) +} diff --git a/lib_logging/src/main/x/logging.x b/lib_logging/src/main/x/logging.x new file mode 100644 index 0000000000..266bd44922 --- /dev/null +++ b/lib_logging/src/main/x/logging.x @@ -0,0 +1,46 @@ +/** + * Corresponds, as a whole module, to the `slf4j-api` jar in the Java ecosystem — plus a + * minimum viable binding (analogous to `slf4j-simple`) so the library is usable out of + * the box. A future `lib_logging_logback` module would correspond to `logback-classic` + * (configuration-driven, multi-appender, hierarchical logger config). + * + * Ecstasy logging library. + * + * The logging library is intentionally shaped to be _instantly familiar_ to anyone who has used + * SLF4J 2.x in the Java ecosystem: named loggers, level checks, parameterized messages with `{}` + * placeholders, exception attachment, markers, MDC, and the SLF4J 2.x fluent event builder API. + * + * The primary entry point is injection: + * + * @Inject Logger logger; + * + * or, naming the logger explicitly: + * + * @Inject("com.example.thing") Logger logger; + * + * Acquiring a logger without injection is also supported via `LoggerFactory`: + * + * Logger logger = LoggerFactory.getLogger("com.example.thing"); + * + * # API / Implementation boundary + * + * The public API consists of: + * - [Logger] — the user-facing facade + * - [Level] — log severities + * - [Marker], [MarkerFactory] + * - [MDC] — mapped diagnostic context + * - [LogEvent] — an immutable record of a single log call + * - [LogSink] — the SPI that backends implement + * - [LoggingEventBuilder] — SLF4J 2.x style fluent API + * - [LoggerFactory] — non-injection acquisition path + * + * The implementation side of that boundary contains: + * - [ConsoleLogSink] — default sink, writes through `@Inject Console` + * - [NoopLogSink] — drops every event + * - [MemoryLogSink] — captures events in memory; useful in tests + * + * A future `lib_logging_logback` module is expected to ship a configuration-driven sink with + * appenders, layouts, filters, and per-logger thresholds — see `docs/LOGBACK_INTEGRATION.md`. + */ +module logging.xtclang.org { +} diff --git a/lib_logging/src/main/x/logging/BasicEventBuilder.x b/lib_logging/src/main/x/logging/BasicEventBuilder.x new file mode 100644 index 0000000000..da8bbcb487 --- /dev/null +++ b/lib_logging/src/main/x/logging/BasicEventBuilder.x @@ -0,0 +1,91 @@ +/** + * Corresponds to `org.slf4j.spi.DefaultLoggingEventBuilder` — the SLF4J 2.x default + * implementation of the fluent builder. Accumulates state, then materializes a single + * call to the underlying logger. + * + * Default `LoggingEventBuilder` implementation. Accumulates state then forwards to the + * underlying `Logger`'s `log(...)` method. + */ +class BasicEventBuilder(Logger logger, Level level) + implements LoggingEventBuilder { + + private String? message = Null; + private Object[] args = new Object[]; + private Marker? marker = Null; + private Exception? cause = Null; + + @Override + LoggingEventBuilder setMessage(String message) { + this.message = message; + return this; + } + + @Override + LoggingEventBuilder addArgument(Object value) { + args.add(value); + return this; + } + + @Override + LoggingEventBuilder addMarker(Marker marker) { + // TODO(api): SLF4J supports multiple markers per event. For now we keep the most + // recently added one; document this until the data model is decided. + this.marker = marker; + return this; + } + + @Override + LoggingEventBuilder setCause(Exception cause) { + this.cause = cause; + return this; + } + + @Override + LoggingEventBuilder addKeyValue(String key, Object value) { + // TODO(api): structured KV requires a per-event map; sinks ignoring KV must still + // accept the call. Wire through to LogEvent in a future iteration. + return this; + } + + @Override + void log() { + if (String m ?= message) { + logger.log(level, m, frozen(args), cause, marker); + } + } + + @Override + void log(String message) { + logger.log(level, message, frozen(args), cause, marker); + } + + @Override + void log(String format, Object arg) { + args.add(arg); + logger.log(level, format, frozen(args), cause, marker); + } + + @Override + void log(String format, Object arg1, Object arg2) { + args.add(arg1); + args.add(arg2); + logger.log(level, format, frozen(args), cause, marker); + } + + @Override + void log(String format, Object[] args) { + for (Object arg : args) { + this.args.add(arg); + } + logger.log(level, format, frozen(this.args), cause, marker); + } + + /** + * Convert the mutable accumulating buffer to a constant-mode (immutable) array so + * it can cross service boundaries on its way to the sink. Equivalent to SLF4J's + * `EventArgArray` materialisation step. + */ + private static Object[] frozen(Object[] mutable) { + return mutable.toArray(Constant); + } +} diff --git a/lib_logging/src/main/x/logging/BasicLogger.x b/lib_logging/src/main/x/logging/BasicLogger.x new file mode 100644 index 0000000000..4cf4de5c76 --- /dev/null +++ b/lib_logging/src/main/x/logging/BasicLogger.x @@ -0,0 +1,109 @@ +/** + * Corresponds, role-wise, to a typical SLF4J binding's `Logger` implementation — + * e.g. `org.slf4j.simple.SimpleLogger` (slf4j-simple), `ch.qos.logback.classic.Logger` + * (Logback). Like `org.slf4j.helpers.AbstractLogger`, this class collapses many caller- + * facing overloads down to a single emission path. + * + * Default `Logger` implementation: a thin forwarder over a `LogSink`. + * + * Holds only the logger's name and a reference to the active sink. All level checks, formatting, + * MDC capture, and event construction happens here so that sinks themselves remain dumb. + * + * User code does not normally instantiate this directly; it is what the runtime hands you in + * response to `@Inject Logger logger;` and what `LoggerFactory.getLogger` returns. + */ +class BasicLogger(String name, LogSink sink) + implements Logger { + + @Inject Clock clock; + // MDC is intentionally not injected here in v0: the runtime side does not yet + // register the MDC resource, and tests construct loggers directly. Once + // `nMainInjector` registers MDC, replace the empty-map snapshot below with + // `mdc.copyOfContextMap`. See doc/logging/OPEN_QUESTIONS.md (entry 3). + + @Override + @RO Boolean traceEnabled.get() = sink.isEnabled(name, Trace); + @Override + @RO Boolean debugEnabled.get() = sink.isEnabled(name, Debug); + @Override + @RO Boolean infoEnabled.get() = sink.isEnabled(name, Info); + @Override + @RO Boolean warnEnabled.get() = sink.isEnabled(name, Warn); + @Override + @RO Boolean errorEnabled.get() = sink.isEnabled(name, Error); + + @Override + Boolean isEnabled(Level level, Marker? marker = Null) { + return sink.isEnabled(name, level, marker); + } + + @Override + void trace(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + emit(Trace, message, arguments, cause, marker); + } + + @Override + void debug(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + emit(Debug, message, arguments, cause, marker); + } + + @Override + void info(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + emit(Info, message, arguments, cause, marker); + } + + @Override + void warn(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + emit(Warn, message, arguments, cause, marker); + } + + @Override + void error(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + emit(Error, message, arguments, cause, marker); + } + + @Override + void log(Level level, String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + emit(level, message, arguments, cause, marker); + } + + @Override + LoggingEventBuilder atTrace() = builderFor(Trace); + @Override + LoggingEventBuilder atDebug() = builderFor(Debug); + @Override + LoggingEventBuilder atInfo() = builderFor(Info); + @Override + LoggingEventBuilder atWarn() = builderFor(Warn); + @Override + LoggingEventBuilder atError() = builderFor(Error); + @Override + LoggingEventBuilder atLevel(Level level) = builderFor(level); + + /** + * Construct an event and hand it to the sink. The fast-path level check is the first thing + * that happens — when disabled, no formatting work is done. + */ + private void emit(Level level, String message, Object[] arguments, Exception? cause, Marker? marker) { + if (!sink.isEnabled(name, level, marker)) { + return; + } + (String formatted, Exception? promoted) = MessageFormatter.format(message, arguments); + Exception? finalCause = cause ?: promoted; + sink.log(new LogEvent( + loggerName = name, + level = level, + message = formatted, + timestamp = clock.now, + marker = marker, + exception = finalCause, + arguments = arguments, + mdcSnapshot = new HashMap(), + threadName = "", // TODO(runtime): expose current-fiber identity + )); + } + + private LoggingEventBuilder builderFor(Level level) { + return new BasicEventBuilder(this, level); + } +} diff --git a/lib_logging/src/main/x/logging/BasicMarker.x b/lib_logging/src/main/x/logging/BasicMarker.x new file mode 100644 index 0000000000..dde3403ec3 --- /dev/null +++ b/lib_logging/src/main/x/logging/BasicMarker.x @@ -0,0 +1,85 @@ +/** + * Corresponds to `org.slf4j.helpers.BasicMarker` — the small in-memory `Marker` + * implementation that ships with `slf4j-api`. + * + * Minimal default `Marker` implementation. Stores child references in a list. Implements + * the [Freezable] contract by freezing its child list on `freeze`; once frozen, `add` and + * `remove` will throw — matching the standard Ecstasy `Freezable` posture. + */ +class BasicMarker(String name) + implements Marker { + + private Marker[] children = new Marker[]; + + @Override + void add(Marker reference) { + if (!children.contains(reference)) { + children.add(reference); + } + } + + @Override + Boolean remove(Marker reference) { + return children.removeIfPresent(reference); + } + + @Override + @RO Boolean hasReferences.get() { + return !children.empty; + } + + @Override + @RO Iterator references.get() { + return children.iterator(); + } + + @Override + Boolean contains(Marker other) { + if (this.name == other.name) { + return True; + } + for (Marker child : children) { + if (child.contains(other)) { + return True; + } + } + return False; + } + + @Override + Boolean containsName(String name) { + if (this.name == name) { + return True; + } + for (Marker child : children) { + if (child.containsName(name)) { + return True; + } + } + return False; + } + + // ---- Freezable ------------------------------------------------------------------------ + + @Override + immutable Marker freeze(Boolean inPlace = False) { + if (this.is(immutable)) { + return this; + } + if (inPlace) { + // Freeze each child in place and the children array as well, then make this immutable. + for (Int i : 0 ..< children.size) { + children[i] = children[i].freeze(inPlace=True); + } + children = children.freeze(inPlace=True); + return makeImmutable(); + } + // Out-of-place: produce a fresh frozen copy. + BasicMarker copy = new BasicMarker(name); + for (Marker child : children) { + copy.children.add(child.freeze(inPlace=False)); + } + copy.children = copy.children.freeze(inPlace=True); + return copy.makeImmutable(); + } +} diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x new file mode 100644 index 0000000000..167c908a8a --- /dev/null +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -0,0 +1,64 @@ +/** + * Corresponds to `org.slf4j.simple.SimpleLogger`'s output behaviour (slf4j-simple) and + * Logback's `ch.qos.logback.core.ConsoleAppender` paired with a basic `PatternLayout`. + * The role is the same: a no-config sink that puts events on stdout/stderr in a fixed, + * human-readable line format. + * + * The default sink: writes one line per event to the platform `Console`. + * + * Format: + * + * 2026-04-29T11:23:45.012Z [thread-name] INFO com.example.foo: hello world + * + * If the event has an exception, the stack trace is appended on subsequent lines. + * + * Configuration is intentionally minimal: a single `rootLevel` threshold applied to every + * logger. Per-logger / per-marker filtering is the job of richer sinks (see + * `docs/LOGBACK_INTEGRATION.md`). + */ +service ConsoleLogSink + implements LogSink { + + @Inject Console console; + + /** + * The threshold below which events are dropped. Defaults to `Info` so production + * code is not flooded with debug output by accident. + */ + public/private Level rootLevel = Info; + + /** + * Adjust the root level at runtime. + */ + void setRootLevel(Level level) { + rootLevel = level; + } + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= rootLevel.severity; + } + + @Override + void log(LogEvent event) { + StringBuffer buf = new StringBuffer(); + buf.append(event.timestamp.toString()) + .append(" [").append(event.threadName).append("] ") + .append(event.level.name.leftJustify(5, ' ')) + .append(' ') + .append(event.loggerName) + .append(": ") + .append(event.message); + + if (Marker m ?= event.marker) { + buf.append(" [marker=").append(m.name).append(']'); + } + + console.print(buf.toString()); + + if (Exception e ?= event.exception) { + // TODO(impl): pretty-print stack frames; for now rely on Exception.toString(). + console.print(e.toString()); + } + } +} diff --git a/lib_logging/src/main/x/logging/Level.x b/lib_logging/src/main/x/logging/Level.x new file mode 100644 index 0000000000..f46231acb1 --- /dev/null +++ b/lib_logging/src/main/x/logging/Level.x @@ -0,0 +1,31 @@ +/** + * Corresponds to `org.slf4j.event.Level` (SLF4J 2.x), `ch.qos.logback.classic.Level`, + * `org.apache.logging.log4j.Level` (Log4j 2), `java.util.logging.Level` (JUL — different + * names but the same idea). + * + * The severity levels used by the logging API. Identical in spirit to SLF4J / Logback / Log4j2: + * `Trace` is the most verbose, `Error` is the most severe. `Off` is reserved for sink-side + * configuration (i.e. "log nothing for this category") and is never used as the level of an + * actual `LogEvent`. + * + * The numeric `severity` field is exposed so sinks can do cheap threshold comparisons: + * + * if (event.level.severity >= threshold.severity) { + * // emit + * } + */ +enum Level(Int severity) { + Trace(10), + Debug(20), + Info (30), + Warn (40), + Error(50), + Off (Int.MaxValue); + + /** + * True iff an event at this level should be emitted given the supplied `threshold`. + */ + Boolean enabledAtThreshold(Level threshold) { + return this.severity >= threshold.severity; + } +} diff --git a/lib_logging/src/main/x/logging/LogEvent.x b/lib_logging/src/main/x/logging/LogEvent.x new file mode 100644 index 0000000000..6126343100 --- /dev/null +++ b/lib_logging/src/main/x/logging/LogEvent.x @@ -0,0 +1,22 @@ +/** + * Corresponds to `org.slf4j.event.LoggingEvent` (SLF4J) and + * `ch.qos.logback.classic.spi.ILoggingEvent` / `LoggingEvent` (Logback). + * + * An immutable record of a single log call. Sinks receive `LogEvent` instances; layouts and + * appenders can read every field freely without worrying about caller-side mutation. + * + * The `mdcSnapshot` is captured at the moment of the + * log call so that asynchronous sinks see the context as it was when the event was raised, not + * as it is when the event is finally written. + */ +const LogEvent( + String loggerName, + Level level, + String message, + Time timestamp, + Marker? marker = Null, + Exception? exception = Null, + Object[] arguments = [], + Map mdcSnapshot = [], + String threadName = "", + ); diff --git a/lib_logging/src/main/x/logging/LogSink.x b/lib_logging/src/main/x/logging/LogSink.x new file mode 100644 index 0000000000..46532671ed --- /dev/null +++ b/lib_logging/src/main/x/logging/LogSink.x @@ -0,0 +1,53 @@ +/** + * Corresponds, conceptually, to two things in the SLF4J/Logback world: + * - `org.slf4j.spi.SLF4JServiceProvider` — the binding/provider boundary you implement + * to plug a backend into SLF4J. + * - `ch.qos.logback.core.Appender` — the per-destination output sink in Logback. + * + * `LogSink` is more like the Logback `Appender`: a single emission target with its own + * level filter. Mapping multiple `LogSink`s onto one logger (Logback's "appender attached + * to logger" model) is the job of a future composite sink — see + * `doc/logging/LOGBACK_INTEGRATION.md`. + * + * The Service-Provider Interface for logging backends. A `LogSink` is the only thing a + * `Logger` ever talks to; everything else (level checks, message formatting, marker + * filtering, MDC capture) is implemented above the sink and is sink-agnostic. + * + * # Why this is the API/impl boundary + * + * Anything _above_ this interface is the public, stable API that user code depends on. + * Anything _below_ this interface is replaceable: `ConsoleLogSink` for the default + * platform-controlled console output, `MemoryLogSink` in tests, a future `LogbackLogSink` + * for configuration-driven file/network appenders, a native sink wrapping `slf4j`+`logback` + * via the JIT bridge — all of those are interchangeable behind this interface. + * + * # Implementing a custom sink + * + * The contract is small: + * - `isEnabled` is the cheap fast-path. It must return promptly and may be called once per + * log statement. Implementations doing per-logger configuration (e.g. logback) consult + * their own configuration tree here. + * - `log` receives a fully-formed `LogEvent`. The message has already had `{}` placeholders + * substituted; the `mdcSnapshot` has already been captured. + * + * See `docs/CUSTOM_SINKS.md` for a worked example. + */ +interface LogSink { + + /** + * Cheap level check. Should be safe to call on every log statement. May consult marker- + * specific or logger-specific configuration to decide. + * + * @param loggerName the name of the logger asking + * @param level the level of the message about to be emitted + * @param marker an optional marker the caller has attached + */ + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null); + + /** + * Emit the supplied event. Must not throw under normal conditions; sinks that experience + * an internal failure (disk full, remote endpoint unreachable) are expected to degrade + * gracefully — typically by writing to standard error — rather than propagate. + */ + void log(LogEvent event); +} diff --git a/lib_logging/src/main/x/logging/Logger.x b/lib_logging/src/main/x/logging/Logger.x new file mode 100644 index 0000000000..947e9d2e38 --- /dev/null +++ b/lib_logging/src/main/x/logging/Logger.x @@ -0,0 +1,94 @@ +/** + * Corresponds to `org.slf4j.Logger`. The same role is played by + * `ch.qos.logback.classic.Logger` (Logback's concrete class), `org.apache.logging.log4j.Logger` + * (Log4j 2), and `java.util.logging.Logger` (JUL). + * + * The user-facing logging facade. + * + * Modeled directly on `org.slf4j.Logger`: per-level methods (`trace`, `debug`, `info`, `warn`, + * `error`), parameterized messages with `{}` placeholders, optional `Exception` cause, optional + * `Marker`, and the SLF4J 2.x fluent event builder via `atInfo()` / `atLevel(...)`. + * + * Acquisition is by injection: + * + * @Inject Logger logger; // anonymous logger + * @Inject("com.example.foo") Logger logger; // named logger + * + * or via the `LoggerFactory` static accessor. + * + * Implementations are free, but the canonical implementation in this module is a thin + * forwarder over a `LogSink` (see `BasicLogger`); user code never sees it directly. + */ +interface Logger { + + /** + * The logger's name. Sinks use this for routing decisions. + */ + @RO String name; + + @RO Boolean traceEnabled; + @RO Boolean debugEnabled; + @RO Boolean infoEnabled; + @RO Boolean warnEnabled; + @RO Boolean errorEnabled; + + /** + * Cheap level check. Equivalent to the per-level shortcuts above; the explicit form is + * preferred when the level is determined dynamically. + */ + Boolean isEnabled(Level level, Marker? marker = Null); + + // ---- Per-level emission ------------------------------------------------------------------- + + void trace(String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + void debug(String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + void info (String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + void warn (String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + void error(String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + /** + * Emit at a runtime-chosen level. Equivalent to `if (level == Info) info(...) else if ...`. + */ + void log(Level level, + String message, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + + // ---- Fluent (SLF4J 2.x style) builder ------------------------------------------------------ + + /** + * Begin a fluent log event at `Trace` level. Example: + * + * logger.atTrace() + * .addMarker(MarkerFactory.getMarker("INTERNAL")) + * .addKeyValue("user", userId) + * .setCause(e) + * .log("processing failed for {}", input); + */ + LoggingEventBuilder atTrace(); + LoggingEventBuilder atDebug(); + LoggingEventBuilder atInfo(); + LoggingEventBuilder atWarn(); + LoggingEventBuilder atError(); + LoggingEventBuilder atLevel(Level level); +} diff --git a/lib_logging/src/main/x/logging/LoggerFactory.x b/lib_logging/src/main/x/logging/LoggerFactory.x new file mode 100644 index 0000000000..9bc2bbc813 --- /dev/null +++ b/lib_logging/src/main/x/logging/LoggerFactory.x @@ -0,0 +1,39 @@ +/** + * Corresponds to `org.slf4j.LoggerFactory` (the static accessor) and + * `org.slf4j.ILoggerFactory` (the interface that bindings implement). The escape-hatch + * accessor for code that cannot use `@Inject`. + * + * Static accessor for `Logger` instances, for code that cannot use `@Inject`. + * + * Mirrors `org.slf4j.LoggerFactory`. The first call into `getLogger` resolves the active + * `LogSink` (via injection) and caches it; subsequent calls return name-keyed `Logger` + * instances backed by that sink. + * + * Library code that wants `@Inject`-free acquisition should typically use: + * + * static Logger logger = LoggerFactory.getLogger("com.example.foo"); + * + * or, when a class object is in scope: + * + * static Logger logger = LoggerFactory.getLogger(MyClass); + */ +service LoggerFactory { + + @Inject LogSink defaultSink; + + private Map cache = new HashMap(); + + /** + * Get the logger for `name`, creating it on first access. + */ + Logger getLogger(String name) { + return cache.computeIfAbsent(name, () -> new BasicLogger(name, defaultSink)); + } + + /** + * Convenience: `getLogger(clz.path)`. + */ + Logger getLogger(Class clz) { + return getLogger(clz.path); + } +} diff --git a/lib_logging/src/main/x/logging/LoggingEventBuilder.x b/lib_logging/src/main/x/logging/LoggingEventBuilder.x new file mode 100644 index 0000000000..69d9aa2752 --- /dev/null +++ b/lib_logging/src/main/x/logging/LoggingEventBuilder.x @@ -0,0 +1,73 @@ +/** + * Corresponds to `org.slf4j.spi.LoggingEventBuilder` (SLF4J 2.x). The default SLF4J impl is + * `org.slf4j.spi.DefaultLoggingEventBuilder`; we ship `BasicEventBuilder` in the same role. + * + * Fluent log-event builder, mirroring `org.slf4j.spi.LoggingEventBuilder` from SLF4J 2.x. + * + * The builder accumulates state then materializes a single `LogEvent` on `log(...)`. When the + * underlying logger is _not_ enabled at the chosen level, the builder is a no-op and all + * accumulated state is discarded — this is the whole point of the design: callers don't pay + * for arguments and cause-chains that are never going to be emitted. + * + * Example: + * + * logger.atInfo() + * .addMarker(MarkerFactory.getMarker("AUDIT")) + * .addKeyValue("requestId", req.id) + * .setCause(e) + * .log("payment {} succeeded for {}", paymentId, customer); + */ +interface LoggingEventBuilder { + + /** + * Set (or replace) the message pattern. Equivalent to passing the message to `log(...)`. + */ + LoggingEventBuilder setMessage(String message); + + /** + * Append an argument used to substitute the next `{}` placeholder in the message. + */ + LoggingEventBuilder addArgument(Object value); + + /** + * Attach a marker. Repeated calls add multiple markers. + */ + LoggingEventBuilder addMarker(Marker marker); + + /** + * Attach an exception to the event. + */ + LoggingEventBuilder setCause(Exception cause); + + /** + * Attach a key/value pair, intended for structured-logging sinks. Sinks that don't + * understand structured KV pairs (e.g. `ConsoleLogSink`) may render them as `key=value` + * after the message or simply ignore them. + */ + LoggingEventBuilder addKeyValue(String key, Object value); + + /** + * Materialize and emit. After `log` is called the builder must not be reused. + */ + void log(); + + /** + * Convenience: same as `setMessage(message).log()`. + */ + void log(String message); + + /** + * Convenience: same as `setMessage(format).addArgument(arg).log()`. + */ + void log(String format, Object arg); + + /** + * Convenience: same as `setMessage(format).addArgument(arg1).addArgument(arg2).log()`. + */ + void log(String format, Object arg1, Object arg2); + + /** + * Convenience: append all `args` then `log()`. + */ + void log(String format, Object[] args); +} diff --git a/lib_logging/src/main/x/logging/MDC.x b/lib_logging/src/main/x/logging/MDC.x new file mode 100644 index 0000000000..581c08aca2 --- /dev/null +++ b/lib_logging/src/main/x/logging/MDC.x @@ -0,0 +1,63 @@ +/** + * Corresponds to `org.slf4j.MDC` (and the underlying `org.slf4j.spi.MDCAdapter`). + * Logback's `LogbackMDCAdapter` and Log4j 2's `ThreadContext` cover the same concept. + * + * Mapped Diagnostic Context — a per-fiber/thread string-keyed scratchpad that sinks can include + * in formatted output. Mirrors `org.slf4j.MDC`. + * + * The default implementation provided by this module is a no-op; values are stored but never + * read by `ConsoleLogSink`. Sinks that want MDC-aware output (a logback-style backend would) + * read `copyOfContextMap` on each event. + * + * Note: the storage is "context-local" in the Ecstasy sense — each fiber sees its own map. + * The exact mechanism is a runtime detail; see `docs/OPEN_QUESTIONS.md` for the design choice. + */ +service MDC { + + private Map map = new HashMap(); + + /** + * Store a value under `key`. Setting `Null` removes the key. + */ + void put(String key, String? value) { + if (value == Null) { + map.remove(key); + } else { + map.put(key, value); + } + } + + /** + * Read the value for `key`, or `Null` if no value is set. + */ + String? get(String key) { + return map.getOrNull(key); + } + + /** + * Remove `key` from the context. + */ + void remove(String key) { + map.remove(key); + } + + /** + * Remove all entries. + */ + void clear() { + map.clear(); + } + + /** + * A snapshot of the current context map. Sinks call this when emitting an event, so the + * captured state is independent of any subsequent mutation by the caller. The snapshot is + * a fresh `HashMap`; callers should treat it as read-only. + */ + @RO Map copyOfContextMap.get() { + HashMap snapshot = new HashMap(); + for ((String k, String v) : map) { + snapshot.put(k, v); + } + return snapshot; + } +} diff --git a/lib_logging/src/main/x/logging/Marker.x b/lib_logging/src/main/x/logging/Marker.x new file mode 100644 index 0000000000..b9f455eb34 --- /dev/null +++ b/lib_logging/src/main/x/logging/Marker.x @@ -0,0 +1,96 @@ +/** + * Corresponds to `org.slf4j.Marker` (SLF4J), with the same semantics as + * `ch.qos.logback.classic.spi.Marker` (Logback) and the marker concept in Log4j 2. + * + * A `Marker` is a named tag that can be attached to a log call to give downstream filters and + * appenders a structured way to route or suppress events without having to grep on message + * content. Mirrors `org.slf4j.Marker`. + * + * Markers can be composed: a marker may have references to other markers (forming a DAG), so + * that a single specific marker can be matched against a broader category. `contains` does the + * transitive check. + * + * # XDK-idiomatic interface design + * + * `Marker` extends three core XDK interfaces: + * + * - **[Freezable]** — markers are carried on `LogEvent` (a `const`); a `const` can only carry + * freezable fields. Anything that may end up on an event must be freezable. This is also + * the property that lets a `Marker` cross a service boundary safely (e.g. when the active + * `LogSink` is a service): the runtime auto-freezes shareable values at the boundary. + * - **[Stringable]** — markers are routinely rendered into formatted log lines and JSON + * payloads. Implementing `Stringable` lets layouts and encoders pre-size their buffers via + * `estimateStringLength()` and avoid extra `toString()` allocations on the hot path. Other + * XDK libs (e.g. `Path`, `URI`, `IPAddress`) follow the same pattern. + * - **[Hashable]** — sinks and filters frequently key off markers (a marker-aware filter + * keeps a `Set` of "interesting" categories). `Hashable` makes that natural and + * makes markers usable as `Map` keys without bridging through their name. + * + * The default implementation in this module ([BasicMarker]) is intentionally minimal — the + * in-memory marker shipped here exists primarily so that user code that *uses* markers + * compiles and runs against the default sink (which mostly ignores them). Real filtering is + * the job of richer sinks (see `doc/logging/LOGBACK_INTEGRATION.md`). + */ +interface Marker + extends Freezable + extends Stringable + extends Hashable { + + /** + * The marker's name. Stable identifier; markers with the same name are considered equal. + */ + @RO String name; + + /** + * Add a child marker as a reference. Idempotent: adding the same reference twice is a + * no-op. Throws `ReadOnly` on a frozen marker. + */ + void add(Marker reference); + + /** + * Remove a previously added reference. Returns True iff the reference was present. + * Throws `ReadOnly` on a frozen marker. + */ + Boolean remove(Marker reference); + + /** + * True iff this marker has any child references. + */ + @RO Boolean hasReferences; + + /** + * The direct child references of this marker. + */ + @RO Iterator references; + + /** + * Transitive containment check: True iff this marker is `other`, or has a (possibly + * indirect) reference to a marker named the same as `other`. + */ + Boolean contains(Marker other); + + /** + * Transitive containment check by name. + */ + Boolean containsName(String name); + + // ---- Stringable ----------------------------------------------------------------------- + + @Override + Int estimateStringLength() = name.size; + + @Override + Appender appendTo(Appender buf) = name.appendTo(buf); + + // ---- Hashable ------------------------------------------------------------------------- + + @Override + static Int64 hashCode(CompileType value) { + return value.name.hashCode(); + } + + @Override + static Boolean equals(CompileType value1, CompileType value2) { + return value1.name == value2.name; + } +} diff --git a/lib_logging/src/main/x/logging/MarkerFactory.x b/lib_logging/src/main/x/logging/MarkerFactory.x new file mode 100644 index 0000000000..fa2665b3f5 --- /dev/null +++ b/lib_logging/src/main/x/logging/MarkerFactory.x @@ -0,0 +1,37 @@ +/** + * Corresponds to `org.slf4j.MarkerFactory` (the static-method facade in SLF4J) plus + * `org.slf4j.IMarkerFactory` (the interface SLF4J's facade delegates to). Logback ships + * `ch.qos.logback.classic.util.LogbackMDCAdapter`'s sibling marker factory; same idea. + * + * Factory for `Marker` instances. Mirrors `org.slf4j.MarkerFactory`. + * + * `getMarker(name)` is _interning_: two calls with the same name return the same instance. + * `getDetachedMarker(name)` always returns a fresh instance, useful when a caller wants a + * marker that is not visible to other callers. + */ +service MarkerFactory { + + /** + * Get (creating if necessary) the canonical marker with the supplied name. Subsequent calls + * with the same `name` return the same marker. + */ + Marker getMarker(String name) { + // TODO(impl): thread-safe interning map. + return new BasicMarker(name); + } + + /** + * Construct a fresh marker that is not registered in the factory's interning map. + */ + Marker getDetachedMarker(String name) { + return new BasicMarker(name); + } + + /** + * True iff a marker with the supplied name has previously been interned. + */ + Boolean exists(String name) { + // TODO(impl): consult the interning map. + return False; + } +} diff --git a/lib_logging/src/main/x/logging/MemoryLogSink.x b/lib_logging/src/main/x/logging/MemoryLogSink.x new file mode 100644 index 0000000000..1912d4dc94 --- /dev/null +++ b/lib_logging/src/main/x/logging/MemoryLogSink.x @@ -0,0 +1,43 @@ +/** + * Corresponds to Logback's `ch.qos.logback.core.read.ListAppender` — the standard + * test-helper appender that captures events in an in-memory list for later assertion. + * + * A test-oriented sink: captures every emitted event in an in-memory list. Suitable for unit + * tests that want to assert on what a logger emitted. + * + * MemoryLogSink sink = new MemoryLogSink(); + * Logger logger = new BasicLogger("test", sink); + * logger.info("processed {}", 42); + * assert sink.events.size == 1; + * assert sink.events[0].level == Info; + */ +class MemoryLogSink + implements LogSink { + + /** + * Threshold below which events are dropped. Defaults to `Trace` so tests see everything. + */ + public/private Level rootLevel = Trace; + + /** + * The captured events, in emission order. + */ + public/private LogEvent[] events = new LogEvent[]; + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= rootLevel.severity; + } + + @Override + void log(LogEvent event) { + events.add(event); + } + + /** + * Discard all captured events. + */ + void reset() { + events.clear(); + } +} diff --git a/lib_logging/src/main/x/logging/MessageFormatter.x b/lib_logging/src/main/x/logging/MessageFormatter.x new file mode 100644 index 0000000000..eb17d0f5a7 --- /dev/null +++ b/lib_logging/src/main/x/logging/MessageFormatter.x @@ -0,0 +1,32 @@ +/** + * Corresponds to `org.slf4j.helpers.MessageFormatter` — the SLF4J helper that turns a + * pattern + arguments into a substituted string and, by SLF4J convention, promotes a + * trailing `Throwable` argument into a separate cause. + * + * `{}`-placeholder substitution, contract-compatible with SLF4J's `MessageFormatter`. + * + * Rules, copied from SLF4J for behavioural parity: + * - Each unescaped `{}` is replaced by the next argument's `toString()`. + * - Excess arguments beyond the placeholder count are ignored. + * - Excess placeholders beyond the argument count are left as-is in the output. + * - A literal `{}` in the message is escaped as `\{}`. + * - A literal backslash before an unrelated `{}` is escaped as `\\{}`. + * - The last argument, if it is an `Exception` and there is no remaining placeholder, is + * interpreted as a `cause` rather than as a substitution. This mirrors SLF4J's + * "throwable promotion" rule. + * + * The result of `format` is a `(String, Exception?)` tuple so callers can route the exception + * separately from the message text. + */ +static service MessageFormatter { + + /** + * Substitute placeholders in `message` with `args`. Returns the formatted message and + * the promoted-throwable (if any). + */ + (String formatted, Exception? cause) format(String message, Object[] args) { + // TODO(impl): port the SLF4J state machine. The skeleton here unblocks compilation + // and other modules that depend on this contract. + return (message, Null); + } +} diff --git a/lib_logging/src/main/x/logging/NoopLogSink.x b/lib_logging/src/main/x/logging/NoopLogSink.x new file mode 100644 index 0000000000..cac8b9fb33 --- /dev/null +++ b/lib_logging/src/main/x/logging/NoopLogSink.x @@ -0,0 +1,25 @@ +/** + * Corresponds to `org.slf4j.helpers.NOPLogger` (and the binding `slf4j-nop`) plus Logback's + * `ch.qos.logback.core.helpers.NOPAppender`. A sink that intentionally produces no output. + * + * A sink that silently drops every event. Useful when: + * - a library wants to default to "no output" unless the embedding application opts in; + * - a test wants to suppress noise without rewriting calling code; + * - benchmarks want to measure caller-side overhead minus sink cost. + * + * `isEnabled` always returns False so callers that respect the level check can skip + * building arguments and snapshotting MDC entirely. + */ +service NoopLogSink + implements LogSink { + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return False; + } + + @Override + void log(LogEvent event) { + // intentionally empty + } +} diff --git a/lib_logging/src/test/x/LoggingTest.x b/lib_logging/src/test/x/LoggingTest.x new file mode 100644 index 0000000000..e5ebfc54a9 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest.x @@ -0,0 +1,18 @@ +/** + * Unit tests for the `logging.xtclang.org` library. + * + * The tests build small, in-memory `LogSink` implementations (a list-backed sink, a + * counting sink) and assert that: + * - the level check fast-path elides emission of disabled events; + * - the per-level methods (`trace`, `debug`, `info`, `warn`, `error`) route correctly; + * - markers and exceptions arrive at the sink intact; + * - the fluent event builder accumulates state and short-circuits on disabled levels; + * - `MessageFormatter` substitutes `{}` placeholders. + * + * Each test class targets one cohesive area; submodules are picked up automatically by + * the xunit engine. + */ +module LoggingTest { + package logging import logging.xtclang.org; + package xunit import xunit.xtclang.org; +} diff --git a/lib_logging/src/test/x/LoggingTest/CountingSink.x b/lib_logging/src/test/x/LoggingTest/CountingSink.x new file mode 100644 index 0000000000..89f3b0268a --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/CountingSink.x @@ -0,0 +1,27 @@ +import logging.Level; +import logging.LogEvent; +import logging.LogSink; +import logging.Marker; + +/** + * Test-only sink: counts events by level. Useful as a reference example for users + * writing their own custom sinks — see `doc/logging/CUSTOM_SINKS.md`. + */ +service CountingSink + implements LogSink { + + public/private Map counts = new HashMap(); + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return True; + } + + @Override + void log(LogEvent event) { + counts.process(event.level, e -> { + e.value = e.exists ? e.value + 1 : 1; + return Null; + }); + } +} diff --git a/lib_logging/src/test/x/LoggingTest/CountingSinkTest.x b/lib_logging/src/test/x/LoggingTest/CountingSinkTest.x new file mode 100644 index 0000000000..2e7abec45d --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/CountingSinkTest.x @@ -0,0 +1,25 @@ +import logging.BasicLogger; +import logging.Logger; + +/** + * Demonstrates that a custom `LogSink` can be plugged in and observed end-to-end. + * The sink is the per-level `CountingSink` defined alongside; this test class drives + * it through a `BasicLogger` and asserts the counts come out right. + */ +class CountingSinkTest { + + @Test + void shouldCountPerLevel() { + CountingSink sink = new CountingSink(); + Logger logger = new BasicLogger("counter", sink); + + logger.info ("a"); + logger.info ("b"); + logger.warn ("c"); + logger.error("d"); + + assert sink.counts[logging.Level.Info] == 2; + assert sink.counts[logging.Level.Warn] == 1; + assert sink.counts[logging.Level.Error] == 1; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/EmissionTest.x b/lib_logging/src/test/x/LoggingTest/EmissionTest.x new file mode 100644 index 0000000000..300c5dbc9c --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/EmissionTest.x @@ -0,0 +1,54 @@ +import logging.BasicLogger; +import logging.Logger; + +/** + * End-to-end emission tests using `BasicLogger` over a `ListLogSink`. Verifies that + * each per-level method routes the right `Level` and that the sink receives the + * correct event content. + */ +class EmissionTest { + + @Test + void shouldRouteInfo() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("emission.test", sink); + + logger.info("hello"); + + assert sink.events.size == 1; + assert sink.events[0].level == logging.Level.Info; + assert sink.events[0].loggerName == "emission.test"; + assert sink.events[0].message == "hello"; + } + + @Test + void shouldRouteAllLevels() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("all.levels", sink); + + logger.trace("t"); + logger.debug("d"); + logger.info ("i"); + logger.warn ("w"); + logger.error("e"); + + assert sink.events.size == 5; + assert sink.events[0].level == logging.Level.Trace; + assert sink.events[1].level == logging.Level.Debug; + assert sink.events[2].level == logging.Level.Info; + assert sink.events[3].level == logging.Level.Warn; + assert sink.events[4].level == logging.Level.Error; + } + + @Test + void shouldCarryException() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("with.cause", sink); + Exception boom = new Exception("boom"); + + logger.error("failure", cause=boom); + + assert sink.events.size == 1; + assert sink.events[0].exception == boom; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/FluentBuilderTest.x b/lib_logging/src/test/x/LoggingTest/FluentBuilderTest.x new file mode 100644 index 0000000000..4bfd8569b2 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/FluentBuilderTest.x @@ -0,0 +1,61 @@ +import logging.BasicLogger; +import logging.BasicMarker; +import logging.Logger; +import logging.Marker; + +/** + * Tests for the SLF4J 2.x style fluent event builder reachable via `logger.atInfo()`, + * `logger.atLevel(level)`, etc. Confirms that: + * - chaining accumulates state and a final `log(...)` materialises a single event; + * - the builder respects the underlying logger's level threshold; + * - markers, causes, and arguments survive the round-trip. + */ +class FluentBuilderTest { + + @Test + void shouldBuildAndEmitInfoEvent() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("fluent", sink); + + logger.atInfo() + .setMessage("processed") + .addArgument(42) + .log(); + + assert sink.events.size == 1; + assert sink.events[0].level == logging.Level.Info; + assert sink.events[0].message == "processed"; + } + + @Test + void shouldCarryMarkerAndCauseThroughBuilder() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("fluent.full", sink); + Marker audit = new BasicMarker("AUDIT"); + Exception cause = new Exception("network down"); + + logger.atError() + .addMarker(audit) + .setCause(cause) + .log("emit failed"); + + assert sink.events.size == 1; + assert sink.events[0].level == logging.Level.Error; + assert sink.events[0].marker?.name == "AUDIT" : assert; + assert sink.events[0].exception == cause; + } + + @Test + void shouldShortCircuitWhenLevelDisabled() { + ListLogSink sink = new ListLogSink(); + sink.setLevel(logging.Level.Warn); + Logger logger = new BasicLogger("fluent.disabled", sink); + + logger.atDebug() + .setMessage("expensive computation result") + .addArgument("would-have-been-formatted") + .log(); + + assert sink.events.size == 0; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/LevelCheckTest.x b/lib_logging/src/test/x/LoggingTest/LevelCheckTest.x new file mode 100644 index 0000000000..69974d02ee --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/LevelCheckTest.x @@ -0,0 +1,55 @@ +import logging.BasicLogger; +import logging.Logger; + +/** + * Verifies the level-check fast path: events below the sink's threshold are dropped + * before any sink-side `log()` work happens, and the per-level `*Enabled` properties + * reflect the threshold faithfully. + */ +class LevelCheckTest { + + @Test + void shouldDropEventsBelowThreshold() { + ListLogSink sink = new ListLogSink(); + sink.setLevel(logging.Level.Warn); + Logger logger = new BasicLogger("threshold", sink); + + logger.trace("ignored"); + logger.debug("ignored"); + logger.info ("ignored"); + logger.warn ("kept"); + logger.error("kept"); + + assert sink.events.size == 2; + assert sink.events[0].level == logging.Level.Warn; + assert sink.events[1].level == logging.Level.Error; + } + + @Test + void shouldReportEnabledFlagsConsistentlyWithThreshold() { + ListLogSink sink = new ListLogSink(); + sink.setLevel(logging.Level.Info); + Logger logger = new BasicLogger("flags", sink); + + assert !logger.traceEnabled; + assert !logger.debugEnabled; + assert logger.infoEnabled; + assert logger.warnEnabled; + assert logger.errorEnabled; + } + + @Test + void shouldDropEverythingAtOff() { + ListLogSink sink = new ListLogSink(); + sink.setLevel(logging.Level.Off); + Logger logger = new BasicLogger("silenced", sink); + + logger.trace("nope"); + logger.debug("nope"); + logger.info ("nope"); + logger.warn ("nope"); + logger.error("nope"); + + assert sink.events.size == 0; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/LevelTest.x b/lib_logging/src/test/x/LoggingTest/LevelTest.x new file mode 100644 index 0000000000..97a4b7550c --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/LevelTest.x @@ -0,0 +1,35 @@ +import logging.Level; + +/** + * Tests for the `Level` enum and its severity ordering. + */ +class LevelTest { + + @Test + void shouldOrderBySeverity() { + assert logging.Level.Trace.severity < logging.Level.Debug.severity; + assert logging.Level.Debug.severity < logging.Level.Info.severity; + assert logging.Level.Info.severity < logging.Level.Warn.severity; + assert logging.Level.Warn.severity < logging.Level.Error.severity; + assert logging.Level.Error.severity < logging.Level.Off.severity; + } + + @Test + void shouldEnableEventsAtOrAboveThreshold() { + assert logging.Level.Info.enabledAtThreshold(logging.Level.Info); + assert logging.Level.Warn.enabledAtThreshold(logging.Level.Info); + assert logging.Level.Error.enabledAtThreshold(logging.Level.Info); + } + + @Test + void shouldDisableEventsBelowThreshold() { + assert !logging.Level.Trace.enabledAtThreshold(logging.Level.Info); + assert !logging.Level.Debug.enabledAtThreshold(logging.Level.Info); + } + + @Test + void shouldSilenceEverythingAtOff() { + assert !logging.Level.Trace.enabledAtThreshold(logging.Level.Off); + assert !logging.Level.Error.enabledAtThreshold(logging.Level.Off); + } +} diff --git a/lib_logging/src/test/x/LoggingTest/ListLogSink.x b/lib_logging/src/test/x/LoggingTest/ListLogSink.x new file mode 100644 index 0000000000..257d04b911 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/ListLogSink.x @@ -0,0 +1,36 @@ +import logging.Level; +import logging.LogEvent; +import logging.LogSink; +import logging.Marker; + +/** + * A test-only sink that captures every event in a linked list. Used by the test cases + * here as a stand-in for a "real" sink — it lets tests assert exactly what the + * `Logger` chose to emit, in order, without any formatting nondeterminism. + * + * `rootLevel` defaults to `Trace` so by default every emitted event is captured. + */ +class ListLogSink + implements LogSink { + + public/private Level rootLevel = logging.Level.Trace; + public/private LogEvent[] events = new LogEvent[]; + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= rootLevel.severity; + } + + @Override + void log(LogEvent event) { + events.add(event); + } + + void setLevel(Level level) { + rootLevel = level; + } + + void reset() { + events.clear(); + } +} diff --git a/lib_logging/src/test/x/LoggingTest/MarkerTest.x b/lib_logging/src/test/x/LoggingTest/MarkerTest.x new file mode 100644 index 0000000000..0046319eb0 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/MarkerTest.x @@ -0,0 +1,52 @@ +import logging.BasicLogger; +import logging.BasicMarker; +import logging.Logger; +import logging.Marker; + +/** + * Tests for `Marker` semantics: equality by name, transitive containment, and end-to- + * end propagation through a `Logger` to a sink. + */ +class MarkerTest { + + @Test + void shouldContainSelf() { + Marker m = new BasicMarker("AUDIT"); + assert m.contains(m); + assert m.containsName("AUDIT"); + } + + @Test + void shouldContainTransitively() { + Marker security = new BasicMarker("SECURITY"); + Marker audit = new BasicMarker("AUDIT"); + Marker breach = new BasicMarker("BREACH"); + + security.add(audit); + audit.add(breach); + + assert security.contains(audit); + assert security.contains(breach); // transitive + assert security.containsName("BREACH"); + } + + @Test + void shouldNotContainUnrelated() { + Marker a = new BasicMarker("A"); + Marker b = new BasicMarker("B"); + assert !a.contains(b); + assert !a.containsName("B"); + } + + @Test + void shouldPropagateMarkerOnEvent() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("with.marker", sink); + Marker audit = new BasicMarker("AUDIT"); + + logger.info("authenticated", marker=audit); + + assert sink.events.size == 1; + assert sink.events[0].marker?.name == "AUDIT" : assert; + } +} diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x new file mode 100644 index 0000000000..9e49af40c3 --- /dev/null +++ b/manualTests/src/main/x/TestLogger.x @@ -0,0 +1,77 @@ +/** + * Manual test that exercises lib_logging end-to-end. + * + * The unit tests under `lib_logging/src/test/x/LoggingTest/` pin down the API surface + * by constructing `BasicLogger` directly against an in-memory sink. That works fine for + * verifying the API, but it does not exercise the **runtime injection** path + * (`@Inject Logger logger;`) — and there are limits to what xunit can express about + * injectability. This module is the place to drive that path explicitly. + * + * Today, until `RTLogger.java` lands in `javatools_jitbridge` and registers a + * `Logger` factory in `nMainInjector`, `@Inject Logger logger` won't actually resolve. + * The fallback in the meantime — and the reference shape user code will write — is to + * construct a `BasicLogger` directly, which is what the `runDirect` method does. Once + * the runtime side lands, `runInjected` will start working with no source change here. + */ +module TestLogger { + package log import logging.xtclang.org; + + @Inject Console console; + + void run() { + console.print("--- runDirect ---"); + runDirect(); + + console.print("--- runInjected (will be a no-op until runtime-side is wired) ---"); + try { + runInjected(); + } catch (Exception e) { + console.print($"runInjected not yet supported: {e.text}"); + } + } + + /** + * Drives the library by constructing a `BasicLogger` over a `ConsoleLogSink` + * explicitly. Works today. + */ + void runDirect() { + log.LogSink sink = new log.ConsoleLogSink(); + log.Logger logger = new log.BasicLogger("TestLogger.direct", sink); + + logger.info ("hello, {}", ["world"]); + logger.warn ("disk space low: {} MB", [42]); + + try { + failingOperation(); + } catch (Exception e) { + logger.error("operation failed", cause=e); + } + + if (logger.debugEnabled) { + logger.debug("this won't appear at the default Info threshold"); + } + + // Marker. + log.Marker audit = new log.BasicMarker("AUDIT"); + logger.info("user signed in", marker=audit); + + // Fluent builder. + logger.atInfo() + .addMarker(audit) + .addKeyValue("requestId", "r_42") + .log("payment processed"); + } + + /** + * Drives the library through `@Inject Logger logger;`. This is what user code is + * meant to write; it depends on runtime work that hasn't happened yet. + */ + void runInjected() { + @Inject log.Logger logger; + logger.info("hello from the injected logger"); + } + + void failingOperation() { + throw new Exception("boom"); + } +} diff --git a/slf4j_full_guide.md b/slf4j_full_guide.md new file mode 100644 index 0000000000..d2812bcb3a --- /dev/null +++ b/slf4j_full_guide.md @@ -0,0 +1,377 @@ +# SLF4J 2.x Provider for a New Language (Full Guide) + +This document is a **complete, copy‑paste ready** guide for implementing a minimal SLF4J 2.x backend (provider) that can initially delegate to Java, while keeping a clean path to a native runtime later. + +--- + +## Architecture + +**SLF4J (Java side)** → **Adapter (your provider)** → **RuntimeLogSink (your abstraction)** → **Backend (JUL now, native later)** + +Key idea: **SLF4J is just an edge adapter**, not your core logging model. + +--- + +## Project Layout + +``` +mylang-slf4j-provider/ + build.gradle.kts + settings.gradle.kts + src/main/java/mylang/slf4j/ + MyLangServiceProvider.java + MyLangLoggerFactory.java + MyLangLogger.java + RuntimeLogSink.java + JulRuntimeLogSink.java + LogLevel.java + LogEvent.java + src/main/resources/ + META-INF/services/org.slf4j.spi.SLF4JServiceProvider +``` + +--- + +## Gradle Build + +```kotlin +plugins { + `java-library` +} + +group = "mylang" +version = "0.1.0" + +repositories { + mavenCentral() +} + +dependencies { + api("org.slf4j:slf4j-api:2.0.17") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} +``` + +--- + +## Service Provider + +```java +package mylang.slf4j; + +import org.slf4j.ILoggerFactory; +import org.slf4j.IMarkerFactory; +import org.slf4j.helpers.BasicMarkerFactory; +import org.slf4j.helpers.BasicMDCAdapter; +import org.slf4j.spi.MDCAdapter; +import org.slf4j.spi.SLF4JServiceProvider; + +public final class MyLangServiceProvider implements SLF4JServiceProvider { + public static final String REQUESTED_API_VERSION = "2.0.99"; + + private ILoggerFactory loggerFactory; + private IMarkerFactory markerFactory; + private MDCAdapter mdcAdapter; + + @Override + public void initialize() { + RuntimeLogSink sink = new JulRuntimeLogSink(); + this.loggerFactory = new MyLangLoggerFactory(sink); + this.markerFactory = new BasicMarkerFactory(); + this.mdcAdapter = new BasicMDCAdapter(); + } + + @Override + public ILoggerFactory getLoggerFactory() { + return loggerFactory; + } + + @Override + public IMarkerFactory getMarkerFactory() { + return markerFactory; + } + + @Override + public MDCAdapter getMDCAdapter() { + return mdcAdapter; + } + + @Override + public String getRequestedApiVersion() { + return REQUESTED_API_VERSION; + } +} +``` + +--- + +## Logger Factory + +```java +package mylang.slf4j; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; + +public final class MyLangLoggerFactory implements ILoggerFactory { + private final RuntimeLogSink sink; + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + public MyLangLoggerFactory(RuntimeLogSink sink) { + this.sink = sink; + } + + @Override + public Logger getLogger(String name) { + return cache.computeIfAbsent(name, n -> new MyLangLogger(n, sink)); + } +} +``` + +--- + +## Runtime Abstraction + +### RuntimeLogSink + +```java +package mylang.slf4j; + +public interface RuntimeLogSink { + boolean isEnabled(String loggerName, LogLevel level); + void log(LogEvent event); +} +``` + +### LogLevel + +```java +package mylang.slf4j; + +public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR +} +``` + +### LogEvent + +```java +package mylang.slf4j; + +public record LogEvent( + String loggerName, + LogLevel level, + String message, + Throwable throwable, + String threadName, + long timestampMillis +) {} +``` + +--- + +## Initial Backend (JUL) + +```java +package mylang.slf4j; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class JulRuntimeLogSink implements RuntimeLogSink { + + @Override + public boolean isEnabled(String loggerName, LogLevel level) { + Logger logger = Logger.getLogger(loggerName); + return logger.isLoggable(toJul(level)); + } + + @Override + public void log(LogEvent event) { + Logger logger = Logger.getLogger(event.loggerName()); + logger.log( + toJul(event.level()), + event.message(), + event.throwable() + ); + } + + private static Level toJul(LogLevel level) { + return switch (level) { + case TRACE -> Level.FINER; + case DEBUG -> Level.FINE; + case INFO -> Level.INFO; + case WARN -> Level.WARNING; + case ERROR -> Level.SEVERE; + }; + } +} +``` + +--- + +## Logger Implementation + +```java +package mylang.slf4j; + +import org.slf4j.Marker; +import org.slf4j.event.Level; +import org.slf4j.helpers.AbstractLogger; +import org.slf4j.helpers.MessageFormatter; + +public final class MyLangLogger extends AbstractLogger { + + private final RuntimeLogSink sink; + + public MyLangLogger(String name, RuntimeLogSink sink) { + this.name = name; + this.sink = sink; + } + + @Override + public boolean isTraceEnabled() { return sink.isEnabled(name, LogLevel.TRACE); } + + @Override + public boolean isDebugEnabled() { return sink.isEnabled(name, LogLevel.DEBUG); } + + @Override + public boolean isInfoEnabled() { return sink.isEnabled(name, LogLevel.INFO); } + + @Override + public boolean isWarnEnabled() { return sink.isEnabled(name, LogLevel.WARN); } + + @Override + public boolean isErrorEnabled() { return sink.isEnabled(name, LogLevel.ERROR); } + + @Override public boolean isTraceEnabled(Marker m) { return isTraceEnabled(); } + @Override public boolean isDebugEnabled(Marker m) { return isDebugEnabled(); } + @Override public boolean isInfoEnabled(Marker m) { return isInfoEnabled(); } + @Override public boolean isWarnEnabled(Marker m) { return isWarnEnabled(); } + @Override public boolean isErrorEnabled(Marker m) { return isErrorEnabled(); } + + @Override + protected String getFullyQualifiedCallerName() { + return MyLangLogger.class.getName(); + } + + @Override + protected void handleNormalizedLoggingCall( + Level level, + Marker marker, + String messagePattern, + Object[] arguments, + Throwable throwable + ) { + String msg = MessageFormatter + .arrayFormat(messagePattern, arguments, throwable) + .getMessage(); + + sink.log(new LogEvent( + name, + map(level), + msg, + throwable, + Thread.currentThread().getName(), + System.currentTimeMillis() + )); + } + + private static LogLevel map(Level level) { + return switch (level) { + case TRACE -> LogLevel.TRACE; + case DEBUG -> LogLevel.DEBUG; + case INFO -> LogLevel.INFO; + case WARN -> LogLevel.WARN; + case ERROR -> LogLevel.ERROR; + }; + } +} +``` + +--- + +## Service Registration + +File: + +``` +META-INF/services/org.slf4j.spi.SLF4JServiceProvider +``` + +Content: + +``` +mylang.slf4j.MyLangServiceProvider +``` + +--- + +## Example Usage + +```java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Demo { + private static final Logger log = LoggerFactory.getLogger(Demo.class); + + public static void main(String[] args) { + log.info("Hello {}", "world"); + } +} +``` + +--- + +## Design Notes + +- Use `AbstractLogger` → avoids implementing 40+ overloads manually +- Use `MessageFormatter` → correct `{}` formatting +- Ignore markers initially +- Use `BasicMDCAdapter` unless you need distributed tracing + +--- + +## Future Evolution + +Replace: + +``` +JulRuntimeLogSink +``` + +with: + +``` +NativeRuntimeLogSink +``` + +No SLF4J changes required. + +--- + +## Core Principle + +**Do NOT design your runtime around SLF4J.** + +Instead: + +- Define your own logging model +- Treat SLF4J as an adapter layer +- Keep full control over your logging semantics + +--- + +End of document. diff --git a/xdk/build.gradle.kts b/xdk/build.gradle.kts index 5d7b7a02bd..c764487c80 100644 --- a/xdk/build.gradle.kts +++ b/xdk/build.gradle.kts @@ -111,6 +111,7 @@ dependencies { xtcModule(libs.xdk.crypto) xtcModule(libs.xdk.json) xtcModule(libs.xdk.jsondb) + xtcModule(libs.xdk.logging) xtcModule(libs.xdk.net) xtcModule(libs.xdk.oodb) xtcModule(libs.xdk.sec) diff --git a/xdk/settings.gradle.kts b/xdk/settings.gradle.kts index bda6186277..df8e552f52 100644 --- a/xdk/settings.gradle.kts +++ b/xdk/settings.gradle.kts @@ -26,6 +26,7 @@ listOf( "lib_net", "lib_json", "lib_jsondb", + "lib_logging", "lib_oodb", "lib_sec", "lib_web", From c40e6c0a95289039bbf2a51931ee1e8a73b611c5 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:27:21 +0200 Subject: [PATCH 02/28] Add runtime-implementation plan for lib_logging round trip Drop a doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md that captures the concrete work required to take the v0 stub from "@Inject Logger logger; parses but does not resolve" to a demo-worthy end-to-end round trip: Stage 1: native-side resolution. RTLogger.java and friends in javatools_jitbridge plus a wildcard-name fallback in nMainInjector.supplierOf and four registrations in addNativeResources. ~half a day of Java. Stage 2: pure-Ecstasy port of SLF4J's MessageFormatter so {} actually substitutes; without this the demo line says "hello {}" rather than "hello world". Plus a few cases ported from SLF4J's reference tests. Stage 3: re-enable @Inject MDC mdc; in BasicLogger and render MDC in ConsoleLogSink. Two-hour task once Stage 1 lands. Stage 4 (optional): XTC compiler-side substitution of the enclosing module name when @Inject Logger is used without a resourceName, giving SLF4J users the "logger named after the class" muscle memory. Stage 5: validate by migrating ~/src/platform's kernel.x, auth/OAuthProvider.x, host/HostManager.x off the ad-hoc console.print "Info :" pattern. The doc supersedes OPEN_QUESTIONS.md items 1, 2, 3, 5, 9, 10 by prescribing concrete fixes; the others (multiple markers per event, service-vs-class for sinks, async, defensive arg copy, lazy lambdas, LogEvent.keyValues) remain genuinely open and are listed at the end of the plan as decisions still to make. doc/logging/README.md updated with a pointer to the new doc under the "What's still uncertain?" heading. --- doc/logging/README.md | 3 + doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md | 314 +++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md diff --git a/doc/logging/README.md b/doc/logging/README.md index 3277782e10..6f31b0f541 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -86,6 +86,9 @@ The docs are organised by question. Pick the one that matches what you want to k ### "What's still uncertain?" +- **[`RUNTIME_IMPLEMENTATION_PLAN.md`](RUNTIME_IMPLEMENTATION_PLAN.md)** — the + actionable plan for turning the v0 stub into a demo-worthy end-to-end round + trip. Stages, ordering, time estimates, definition of "demo worthy". - **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** — running list of unresolved design and implementation questions, with the trade-offs and a tentative recommendation for each. None block the v0 stub; they block "v0 working end-to-end". diff --git a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md b/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..b411d494eb --- /dev/null +++ b/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md @@ -0,0 +1,314 @@ +# Runtime implementation plan — getting to a demo-worthy round trip + +This document is the actionable plan that turns the v0 stub (`@Inject Logger logger;` +parses but does not resolve) into a **working end-to-end demo**: the Ecstasy line + +```ecstasy +@Inject Logger logger; +logger.info("hello {}", ["world"]); +``` + +producing real output on the platform `Console` via the runtime injector. Every item +here either is on the critical path to that demo, or is needed to call the round trip +"complete" (parameterized messages actually substitute, MDC actually propagates, etc.). + +The doc supersedes `OPEN_QUESTIONS.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete +fixes; the others remain genuinely open. + +## Definition of "demo worthy" + +The round trip is demo-worthy when **all** of these are true: + +1. A standalone Ecstasy program containing only + ```ecstasy + module Demo { + package log import logging.xtclang.org; + void run() { + @Inject log.Logger logger; + logger.info("hello {}", ["world"]); + } + } + ``` + compiles, runs, and prints a single timestamped line containing `hello world` + without any explicit `BasicLogger` / `LogSink` wiring. + +2. The same demo with `@Inject("com.example") log.Logger logger;` resolves to a + logger named `com.example` (output line carries the name). + +3. `logger.error("failed: {}", [id], cause=new Exception("boom"));` emits both the + message line and the exception text. + +4. `logger.atInfo().addMarker(AUDIT).addKeyValue("k", v).log("msg")` works through the + fluent builder. + +5. MDC `mdc.put("requestId", id)` shows up alongside any subsequent emission (rendered + by a sink that cares; default `ConsoleLogSink` would gain a small change to render + MDC entries). + +6. The 18 unit tests still pass; one new manualTest invokes `runInjected()` and + succeeds (today the `runInjected()` path catches the unresolved-injection + exception). + +7. `~/src/platform/kernel/kernel.x` compiles unchanged (it does not use + `@Inject Logger` yet) AND a small follow-up PR converting one of its + `console.print($"... Info :")` calls to `logger.info(...)` works end-to-end. + +If all seven hold, we can demo `lib_logging` to anyone. + +## Critical path + +These items are sequenced by hard dependency. + +### Stage 1 — Native side: make `@Inject Logger` resolve + +Three native files in `javatools_jitbridge/`, plus a registration line in +`nMainInjector`. Mirrors the `TerminalConsole.java` pattern exactly. + +#### 1.1 — `RTLogger.java` + +`javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogger.java`. + +A native service that wraps an Ecstasy `BasicLogger` instance. The `$create(Object +opts)` factory receives the resource name (the string passed in `@Inject("foo") +Logger`) and bakes it into the constructed logger: + +- Constructor takes `(String name, LogSink sink)`. The `sink` argument resolves via + the same injector — this is the second native registration below. +- Implements every method on the Ecstasy `Logger` interface using the `$p` suffix + convention (`info$p(Ctx, String, Array, Exception?, Marker?)`, etc.). +- All emission methods delegate to an inner pure-Ecstasy `BasicLogger` constructed + once per resource name. + +#### 1.2 — `RTConsoleLogSink.java` + +`javatools_jitbridge/.../logging/RTConsoleLogSink.java`. + +The native default sink. Wraps `ConsoleLogSink` (Ecstasy) and is what +`@Inject LogSink defaultSink` resolves to when no application override is registered. +Bootstrap-safe: even if `Console` is not yet registered, falls back to +`System.err.println` so first-line logging during early init can never deadlock. + +#### 1.3 — `RTMarkerFactory.java`, `RTMDC.java` + +Same pattern, smaller. Both wrap their Ecstasy counterparts. `RTMDC` needs a per-fiber +storage decision — see Stage 2 below; until that's resolved, it holds a per-instance +`HashMap` and we accept that MDC is per-container, not per-fiber. + +#### 1.4 — Wildcard-name resolution in `nMainInjector` + +`javatools_jitbridge/.../mgmt/nMainInjector.java`. Today `supplierOf(Resource)` does +exact `(TypeConstant, String)` lookup. Modify to: + +- First try the exact key. +- If miss and the type is `Logger`, fall back to a wildcard entry registered as + `(loggerType, "*")`. The factory receives the requested resource name as `opts` and + produces a logger of that name. + +Eight or ten lines of new Java. Opts to make it generic over types if the same need +arises elsewhere, but for v0 a `Logger`-specific fallback is fine. + +#### 1.5 — Registration in `addNativeResources()` + +The single-line additions: + +```java +suppliers.put(new Resource(loggerType, "*"), RTLogger::$create); +suppliers.put(new Resource(logSinkType, "default"), RTConsoleLogSink::$create); +suppliers.put(new Resource(markerFactoryType, "markers"), RTMarkerFactory::$create); +suppliers.put(new Resource(mdcType, "mdc"), RTMDC::$create); +``` + +`loggerType` etc. are resolved via the existing TypeConstant lookup machinery the same +way `consoleType` is. + +**Done state of Stage 1:** `@Inject Logger logger;` returns a working `Logger` +instance. Tests still pass. The `runInjected()` path of `manualTests/.../TestLogger.x` +runs without throwing. + +### Stage 2 — Make the message actually format + +Right now `logger.info("hello {}", ["world"])` emits the literal `hello {}`. Fixing +this is a pure-Ecstasy port of SLF4J's `MessageFormatter.format` state machine. + +#### 2.1 — Real `MessageFormatter.format` + +Port the algorithm from `org.slf4j.helpers.MessageFormatter` (it's a small, well- +defined state machine). Specifically: + +- Walk the `message` character by character. +- Track escape state: `\{` is a literal `{`, `\\{` is a literal backslash followed by a + placeholder. +- For each unescaped `{}`, consume the next argument's `toString()` and append. +- Excess placeholders left literal; excess arguments dropped. +- If the last argument is an `Exception` and there is no remaining placeholder, + promote it to the returned `cause`. + +#### 2.2 — Tests + +Port a subset of SLF4J's `MessageFormatterTest` cases — empty patterns, single +placeholder, multi placeholder, escape handling, throwable promotion, mismatched +counts. ~10 cases is enough to be confident. + +#### 2.3 — Wire into BasicLogger + +Already wired (`BasicLogger.emit` calls `MessageFormatter.format`). Once the formatter +is real, the wiring works. + +**Done state of Stage 2:** `logger.info("hello {}", ["world"])` outputs +`... INFO Demo: hello world`. The throwable-promotion rule handles +`logger.warn("cleanup failed", [], cause=e)` correctly. + +### Stage 3 — Re-enable MDC + +#### 3.1 — Decide the scope + +Pick one: + +- **a) Per-fiber.** Matches Java ThreadLocal MDC. Requires the runtime to give us + fiber-local storage. The `RTMDC` native impl uses that. +- **b) Per-service-instance.** Cheap to implement; semantically wrong for typical + request-scoped use, because anything inside a non-trivial fiber tree shares a single + MDC service, so concurrent requests would step on each other. + +Recommendation: **(a)** if the runtime can support it — there is a known pattern in +the JVM bridge for context-local state. If not, ship (b) for v0 with a clear "this is +known-incorrect for concurrent use" warning in the doc, and revisit before any real +production user picks this up. + +#### 3.2 — Re-enable `@Inject MDC mdc;` in `BasicLogger` + +The injection was removed in v0 because the runtime didn't register the resource. Once +Stage 1 lands, restore the field and replace the `new HashMap()` in +`emit()` with `mdc.copyOfContextMap`. + +#### 3.3 — Render MDC in `ConsoleLogSink` + +One-liner: append `[mdc=k1=v1,k2=v2]` to the formatted line if `event.mdcSnapshot` is +non-empty. + +**Done state of Stage 3:** `mdc.put("requestId", id)` followed by +`logger.info("dispatch")` produces a line containing `[mdc=requestId=...]`. + +### Stage 4 — Compiler-side default name (optional but high-impact) + +Today `@Inject Logger logger;` (no `resourceName`) resolves to a logger named +`"default"`. Every SLF4J user instinctively expects the logger to be named after the +enclosing module/class. The fix is a small XTC compiler change. + +#### 4.1 — Substitute the enclosing module name + +When the compiler sees `@Inject Logger logger;` with `resourceName == Null`, +substitute the enclosing module's qualified name as the resource name. This is one +location in the compiler; the dispatch on type `Logger` is the only special case. + +#### 4.2 — Document + +Update `INJECTED_LOGGER_EXAMPLE.md` and `SLF4J_PARITY.md` to reflect the implicit- +naming behaviour. + +**Done state of Stage 4:** `@Inject Logger logger;` inside `module PaymentService` +emits lines tagged `PaymentService:`. The cheat sheet in +`INJECTED_LOGGER_EXAMPLE.md` becomes accurate without `@Inject("PaymentService")`. + +This stage is **optional for the demo**. Without it, the demo writes +`@Inject("PaymentService") Logger logger;` explicitly. With it, the demo is one line +shorter and matches SLF4J muscle memory. + +### Stage 5 — Validation: platform repo migration + +The end-to-end test of "demo worthy" is migrating real code. + +#### 5.1 — Pick three files in `~/src/platform` + +`kernel/kernel.x`, `auth/OAuthProvider.x`, `host/HostManager.x` are the highest- +density `console.print($"... Info :")` files. See `PLATFORM_AND_EXAMPLES_ADAPTATION.md`. + +#### 5.2 — Migrate each + +Mechanical: replace `console.print($"{common.logTime($)} Info : ...")` with +`logger.info("...")`. Drop the `common.logTime` helper after the last caller is +gone. + +#### 5.3 — Confirm output + +Run the platform; confirm log lines look as expected. Capture before/after samples for +the demo. + +**Done state of Stage 5:** the platform repo's logging looks identical from outside +(maybe slightly cleaner); from inside, every log site is now an `@Inject Logger` +call. The before/after diff is the demo. + +## Open questions still open after this plan + +The plan above closes runtime-side gaps. These items remain genuinely unresolved and +need a decision separately: + +- **Multiple markers per event** (`OPEN_QUESTIONS.md` #4). API-level: should we change + `LogEvent.marker: Marker?` to `LogEvent.markers: Marker[]` and update the fluent + builder to accumulate? Cheap to do now, breaks callers later. +- **Service vs class for sinks** (`OPEN_QUESTIONS.md` #6). Currently the recommendation + is "don't require service"; the question is whether to formalize that in the + interface signature. +- **Async / batched sinks** (`OPEN_QUESTIONS.md` #7). Defer to `lib_logging_logback`? + Or ship a simple `AsyncLogSink` wrapper here? +- **Per-container override convenience** (`OPEN_QUESTIONS.md` #8). Probably "no + helper, document the pattern" — but worth deciding before there are users. +- **Defensive copy of caller `Object[]`** (`OPEN_QUESTIONS.md` #11). Now that args are + frozen-on-the-way-into-the-event for builder calls, the per-level methods (which + accept caller-supplied arrays) might still see mutation. Decide: copy in + `BasicLogger.emit` always, or document caller-must-not-mutate. +- **Lazy logging lambdas** (`LAZY_LOGGING.md`). When do we add the + `info(function String () messageFn, ...)` overloads? Easy to add; question is + timing. +- **Structured `keyValues` field on `LogEvent`** (`STRUCTURED_LOGGING.md`). Add now or + with the first sink that consumes them? + +## Recommended sequencing + +``` +Stage 1.1 RTLogger.java ┐ +Stage 1.2 RTConsoleLogSink.java ├─ same PR, ~half a day +Stage 1.3 RTMarkerFactory + RTMDC │ +Stage 1.4 wildcard resolution │ +Stage 1.5 register in nMainInjector ┘ + │ + ▼ demo runs but message says "hello {}" +Stage 2 MessageFormatter port + tests ── ~half a day + │ + ▼ demo says "hello world" +Stage 3 MDC re-enable + render ── ~2 hours + │ + ▼ full v0.1 round trip +Stage 4 compiler-side default name ── ~half a day, separate PR + │ + ▼ feels like SLF4J +Stage 5 platform migration ── ~1-2 days, follow-up PR +``` + +Total to demo-worthy: **2-3 working days**, split across one runtime PR and one +follow-up that polishes message formatting and MDC. The compiler change and the +platform migration are independent and can land later. + +## What this plan does *not* cover + +- The future `lib_logging_logback` module (`LOGBACK_INTEGRATION.md`). That's a + separate, larger project for a configurable hierarchical backend. +- The native-Logback bridge approach (`NATIVE_BRIDGE.md`). Documented as feasible but + not the primary path. +- Slog-style alternative API (`ALTERNATIVE_DESIGN_SLOG_STYLE.md`). Documented as a + thinkable alternative but not pursued. +- Performance tuning (allocation, async). Premature; revisit when the platform repo + has been on `lib_logging` for long enough that we have data. + +## Owner-readable bullet summary + +If you want this in three lines: + +- **Two days of Java work** in `javatools_jitbridge` makes `@Inject Logger` work. +- **Half a day of Ecstasy work** ports the SLF4J `{}` substitution and turns + `hello {}` into `hello world`. +- **Optional half day of compiler work** turns `@Inject Logger` into the + module-scoped logger SLF4J users expect, instead of always falling back to + `"default"`. + +After that, the platform-repo migration PR is the demo. From 9d3b0be25b7788d0a0fb0a7753905fc8ca5338b6 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:29:47 +0200 Subject: [PATCH 03/28] Trim OPEN_QUESTIONS.md and link addressed items to the runtime plan Six of the twelve original open-questions entries (1, 2, 3, 5, 9, 10) are now prescribed concrete fixes in RUNTIME_IMPLEMENTATION_PLAN.md. Collapse those to one-line summaries with a link to the relevant stage instead of repeating the full trade-off discussion in two places. The six items that remain genuinely open (4, 6, 7, 8, 11, 12) keep their full discussion since someone will have to decide. A short "decisions required to land in this order" section at the bottom calls out the three items (#3, #4, #11) that need an answer before or alongside Stage 1 of the runtime plan, so they don't quietly block the implementation. --- doc/logging/OPEN_QUESTIONS.md | 208 ++++++++++++---------------------- 1 file changed, 75 insertions(+), 133 deletions(-) diff --git a/doc/logging/OPEN_QUESTIONS.md b/doc/logging/OPEN_QUESTIONS.md index 0a743bafb1..5a29ae6c63 100644 --- a/doc/logging/OPEN_QUESTIONS.md +++ b/doc/logging/OPEN_QUESTIONS.md @@ -1,184 +1,126 @@ # Open questions for `lib_logging` -A running list of unresolved design and implementation questions. Each entry includes -the trade-offs as currently understood and a tentative recommendation. None of these -are blockers for the v0 stub; they are blockers for "v0 actually works end-to-end". +This is the running list of unresolved design and implementation questions, kept +deliberately short so it stays readable as the project moves. -## 1. Wildcard-name injection in `nMainInjector` +It is split into two sections: -The runtime injection key is `(TypeConstant, String name)` and resolves by exact match. -For `@Inject Logger logger`, the empty/default name is fine. For -`@Inject("com.example.foo") Logger logger`, the name varies per call site and cannot be -exhaustively enumerated. +- **Addressed by the runtime plan** — questions that *had* been open, but + [`RUNTIME_IMPLEMENTATION_PLAN.md`](RUNTIME_IMPLEMENTATION_PLAN.md) now prescribes a + concrete fix. Those are summarized in one line each, with a link. +- **Still genuinely open** — questions whose answer the plan does not commit to. These + retain their full trade-off discussion, because someone will have to decide. -**Options:** - -- **A. Wildcard match.** Make `nMainInjector.supplierOf` fall back to a wildcard entry - (`"*"`) when no exact match is found. The factory receives the requested name as - `opts` and bakes it into the proxy. This is one-method change. -- **B. Generalize Resource resolution.** Make `Resource` keys support type-only entries - with name passed through. Bigger change, broader blast radius. - -**Tentative recommendation:** A. +When an "addressed by the plan" item lands in code, delete its row. -## 2. Default logger name when no `resourceName` supplied +--- -`@Inject Logger logger;` has no name. What should the resulting logger's name be? +## Addressed by the runtime implementation plan -**Options:** - -- **A. The literal string `"default"`.** Simple, predictable, but means every - injection-without-name across an application maps to the same logger. -- **B. The enclosing module's qualified name.** Requires the XTC compiler to substitute - the name at compile time. More useful for hierarchical logger configuration but is a - compiler-side change. -- **C. The fully-qualified name of the enclosing class/method.** Like SLF4J's - `LoggerFactory.getLogger(MyClass.class)`. Strictly the most useful default. +| # | Question | Resolution | +|---|---|---| +| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | `nMainInjector.supplierOf` falls back to a wildcard `(loggerType, "*")` entry. See [Stage 1.4](RUNTIME_IMPLEMENTATION_PLAN.md#14--wildcard-name-resolution-in-nmaininjector). | +| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](RUNTIME_IMPLEMENTATION_PLAN.md#stage-4--compiler-side-default-name-optional-but-high-impact). | +| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](RUNTIME_IMPLEMENTATION_PLAN.md#31--decide-the-scope). | +| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](RUNTIME_IMPLEMENTATION_PLAN.md#21--real-messageformatterformat). | +| 9 | **Where does the runtime live?** | `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/`, registered from `nMainInjector.addNativeResources()`. See [Stage 1.1–1.5](RUNTIME_IMPLEMENTATION_PLAN.md#stage-1--native-side-make-inject-logger-resolve). | +| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Yes — `RTConsoleLogSink.java` falls back to `System.err.println` if `Console` is not yet available. See [Stage 1.2](RUNTIME_IMPLEMENTATION_PLAN.md#12--rtconsolelogsinkjava). | -**Tentative recommendation:** A for v0 (no compiler change). Track B/C as a follow-up -once the rest of the wiring is solid. +--- -## 3. MDC scope: per-fiber, per-service, or per-call? +## Still genuinely open -`MDC` carries context that should follow a request through nested calls without being -threaded explicitly. The right scope depends on Ecstasy's concurrency story. +These need a decision before the API is locked in. Numbered to preserve traceability +to earlier discussion. -**Options:** - -- **A. Per-fiber.** Each fiber sees its own context. Matches Java's `ThreadLocal` MDC - semantics. Requires runtime support for per-fiber locals. -- **B. Per-service.** Every service instance has its own MDC. Simplest; matches the - `service` keyword's existing isolation model. -- **C. Explicit propagation.** Loggers carry their own MDC; deriving a logger derives - the context. This is the slog-style approach. - -**Tentative recommendation:** A if Ecstasy gives us per-fiber locals; B if not. C is the -"Logger.with" path documented in `ALTERNATIVE_DESIGN_SLOG_STYLE.md` and would be a -separate API addition rather than a replacement. - -## 4. Multiple markers per event +### 4. Multiple markers per event SLF4J 2.x's `LoggingEventBuilder.addMarker` accepts repeated calls and stores all of -them on the event. Our `BasicEventBuilder` currently keeps only the most recently added +them on the event. `BasicEventBuilder` currently keeps only the most recently added one. **Options:** -- **A. Match SLF4J — list of markers per event.** Requires `LogEvent.marker` → - `Marker[] markers`. Affects `LogSink` callers everywhere. +- **A. Match SLF4J — list of markers per event.** Requires `LogEvent.marker: Marker?` + → `LogEvent.markers: Marker[]`. Affects every sink that reads markers. - **B. Keep one marker, document the limitation.** Sinks that need multiple markers - can use child references on a single marker (the `Marker.add(other)` mechanism). + can use child references on a single marker. -**Tentative recommendation:** A — match SLF4J. The cost is a one-line type change and a -small loop in the few sinks that read it; doing this in v0 is much cheaper than -breaking callers later. +**Recommendation:** A. Cheap to do now (a few lines, plus updating two test +assertions); breaking later is much more work. -## 5. Throwable promotion: where does it happen? +### 6. Service vs class for sinks -SLF4J's rule: a trailing `Throwable` argument with no matching placeholder is treated as -the cause. Today our `MessageFormatter.format` returns `(formatted, cause?)` so the -formatter is the right place. But callers can also pass `cause` explicitly. Which wins? +Most `LogSink` implementations are services (mutable thread-safe state); some are not. **Options:** -- **A. Explicit `cause` always wins.** If the caller passed `cause=e`, ignore any - promoted-from-args throwable. -- **B. Promoted always wins.** Surprising; callers expect their explicit `cause` to be - honoured. -- **C. Error if both supplied.** Programming error. +- **A. Recommend, don't require.** `LogSink` stays an interface; impls choose. +- **B. Require.** Force `LogSink extends Service` in the interface signature. -**Tentative recommendation:** A. Document explicitly. +**Recommendation:** A. Forcing service-hood breaks `NoopLogSink` and complicates test +helpers; users who need state will reach for `service` naturally. -## 6. Service vs class for sinks +### 7. Async / batched sinks -Most `LogSink` implementations want to be services (mutable state — counters, queues, -file handles). Should the contract require it? +Where does the async-wrapper sink live? **Options:** -- **A. Recommend, don't require.** `LogSink` is an interface; implementations choose. -- **B. Require.** `interface LogSink extends Service` (or whatever the Ecstasy - equivalent is). +- **A. Ship `AsyncLogSink` in `lib_logging`.** Caller wraps a slow sink: `new + AsyncLogSink(new ConsoleLogSink())`. Worker fiber drains a bounded queue. +- **B. Defer to `lib_logging_logback`.** Keeps the base lib minimal. -**Tentative recommendation:** A. Forcing service-hood breaks the trivial cases -(`NoopLogSink` doesn't need it; testing helpers may not need it). Users who build -stateful sinks will reach for `service` naturally. +**Recommendation:** B. The configurable backend is the natural home for async; basics +ship in v0 unchanged. -## 7. Async / batched sinks +### 8. Per-container override convenience -The default sinks are synchronous. A real production system wants async to keep slow -I/O off the caller path. When and how do we ship one? +Ecstasy's container model gives each container its own injector. A host could in +principle want a different sink for one nested container. **Options:** -- **A. Ship `AsyncLogSink` as a wrapper in `lib_logging`.** Caller does - `new AsyncLogSink(new ConsoleLogSink())`. Worker fiber drains a bounded queue. -- **B. Defer to `lib_logging_logback`.** That module is the natural home for the - async-appender story; keeping `lib_logging` minimal is good. - -**Tentative recommendation:** B. Ship the basics in v0; async lives in the configurable -backend. - -## 8. Configuration override per container - -Ecstasy's container model allows nested containers each with their own injector. A -guest module nested in a host could in principle want a different logger sink than the -host. Today the `nMainInjector.suppliers` map is per-instance, which is the right -foundation. The question is the *user-facing API*: how does a host install a different -sink for one nested container? - -**Options:** - -- **A. Document that the host configures its child container's injector explicitly.** +- **A. Document the pattern.** "Configure the child container's injector explicitly." No API addition. -- **B. Provide a helper, e.g. `ContainerLoggingConfig.set(child, sink)`.** - -**Tentative recommendation:** A for v0. Revisit if there's demand. +- **B. Provide a helper.** Something like `ContainerLoggingConfig.set(child, sink)`. -## 9. Where does the runtime live? +**Recommendation:** A. Wait for demand. -The `BasicLogger` and the sink classes are pure Ecstasy. The runtime piece — the actual -`@Inject Logger` resolution — needs Java code in `javatools_jitbridge`. The directory -layout question: +### 11. Defensive copy of caller-supplied `Object[] arguments` -- `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogger.java` -- `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogSink.java` -- Registration in `javatools_jitbridge/src/main/java/org/xtclang/_native/mgmt/nMainInjector.java` +`BasicEventBuilder` already converts its accumulated `args` to `Constant`-mode (frozen) +before crossing the sink boundary. The per-level methods (`info`, `debug`, …) accept +`Object[]` from the caller directly. If the caller mutates that array between the +return of `info(...)` and an async sink consuming the event, the sink may see the +mutation. -Convention to confirm with a maintainer; mirrors `_native/io/TerminalConsole.java`. - -## 10. Default sink: pure-Ecstasy or a thin native bootstrap? - -The default `ConsoleLogSink` is written in Ecstasy and goes through `@Inject Console`. -Bootstrap-wise, this means the sink resolution has to happen *after* `Console` is -registered. This is fine in practice (both are registered in `addNativeResources`) but -it's an ordering constraint worth noting. - -If for any reason `ConsoleLogSink` cannot be resolved during early-runtime logging, -should there be a tiny native fallback that `System.err.println`s? SLF4J does this with -`SubstituteLoggerFactory`. +**Options:** -**Tentative recommendation:** Yes, a tiny native fallback. It's a few lines and removes -an entire class of "logger isn't ready yet" failure mode. +- **A. Defensive copy at the `BasicLogger.emit` boundary.** One allocation per + emission. +- **B. Document that callers must not mutate the array.** Match SLF4J's posture. -## 11. LogEvent immutability and `Object[]` arguments +**Recommendation:** B for v0. Reconsider if/when async sinks become common. -`LogEvent` is a `const`. The `arguments: Object[]` field, however, holds caller-supplied -references. If the caller mutates the array between `Logger.info(...)` returning and an -async sink consuming the event, the sink sees the mutation. +### 12. Compiler/tooling support for log statements -**Options:** +SLF4J has linters that flag `info("count: " + n)` (eagerly formatted instead of using +`{}`) and missing exception arguments. Should Ecstasy? -- **A. Defensive copy at the `BasicLogger.emit` boundary.** One allocation per emission. -- **B. Document that callers must not mutate the array.** Match SLF4J's posture (which - is the same). +**Recommendation:** Out of scope for `lib_logging`. If we want it, it's an XTC linter +feature, not a library feature. -**Tentative recommendation:** B for v0. Reconsider if async sinks become common. +--- -## 12. Compiler/tooling support for log statements +## Decisions required to land in this order -SLF4J has `slf4j-tools` that lints for things like `info("count: " + n)` (eagerly -formatted instead of using `{}`) and missing exception arguments. Would Ecstasy benefit? +1. **MDC scope (#3)** — must be made before Stage 1.3 of the plan. Without an answer + the `RTMDC.java` impl can't be written. +2. **Multiple markers per event (#4)** — should be made *before or with* Stage 1, so + `LogEvent` and `BasicEventBuilder` shapes are stable when the runtime starts + resolving them. +3. **Defensive copy (#11)** — make alongside Stage 1.1; the `RTLogger` Java side + needs to know whether to copy or not. -**Tentative recommendation:** Out of scope for `lib_logging`. If we want it, it's an -XTC compiler/linter feature, not a library feature. +The remaining still-open items (#6, #7, #8, #12) can wait until there are real users. From a346493e17db6263ec4250165b917e44c197c1ea Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Fri, 1 May 2026 22:08:07 +0200 Subject: [PATCH 04/28] Mature lib_logging end-to-end: const sweep, multi-marker, interning Bring the SLF4J-shaped library to a working, comparable POC. End-to-end @Inject Logger now resolves through NativeContainer.ensureLogger, MDC propagates per-fiber across the const Logger boundary, and the SLF4J 2.x fluent multi-marker idiom round-trips through the sink. - BasicLogger is now a const, holding (name, sink, registry?). Methods run on the caller's fiber so MDC SharedContext lookups survive injection; removing the xRTLogger service wrapper was needed for that and simplifies the runtime side. - NoopLogSink and ConsoleLogSink are const (stateless forwarders). MemoryLogSink and ListLogSink stay service (they aggregate state). The const-vs-service rule for sinks is documented in DESIGN.md and on every concrete sink. - LogEvent.markers: Marker[] (was Marker?) with a backwards-compat marker accessor returning markers[0]. BasicEventBuilder accumulates per addMarker; ConsoleLogSink renders [marker=N] / [markers=A,B] depending on count. - LoggerRegistry interns child loggers when attached. LoggerFactory wires its own registry over the default sink so getLogger("a") and root.named("a") have stable identity. Without a registry, named() allocates fresh. - MDC is a const over a SharedContext> with copy-on-write derivation; child fibers see parent state without being able to mutate it back. - MessageFormatter is fully implemented (the "stub" claim in earlier docs was stale): {} substitution, \\{} escape, double-escape, trailing-throwable promotion, defensive safeToString. Twelve tests. - NativeContainer registers the supplier against the Logger interface type (typeLoggerIf) but constructs a BasicLogger internally; lookup is exact-key so registering against BasicLogger missed @Inject Logger logger;. - Test sinks expose events as immutable snapshots so service-boundary returns work; backing array stays mutable internally. - Settled questions: Q1 (rejected wildcard injection), Q3 (MDC per-fiber), Q4 (multi-marker), Q5 (throwable promotion in formatter, explicit-cause wins), Q6 (const/service sink rule), Q8 (per-container override = document the pattern), Q9/10 (runtime location/bootstrap), Q11 (no defensive copy in v0). - 51 unit tests passing in lib_logging; manualTests TestLogger demos all four sections (runDirect, runInjected, runInjectedByName, runMDC) end-to-end. # Conflicts: # javatools/src/main/java/org/xvm/runtime/NativeContainer.java --- doc/logging/CUSTOM_SINKS.md | 20 +++ doc/logging/DESIGN.md | 147 ++++++++++++++++-- doc/logging/INJECTED_LOGGER_EXAMPLE.md | 109 +++++++++---- doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md | 123 +++++++++++---- doc/logging/SLF4J_PARITY.md | 12 +- .../java/org/xvm/runtime/NativeContainer.java | 84 ++++++++++ javatools_bridge/build.gradle.kts | 1 + javatools_bridge/src/main/x/_native.x | 11 +- .../src/main/x/logging/BasicEventBuilder.x | 60 +++++-- lib_logging/src/main/x/logging/BasicLogger.x | 97 +++++++++++- .../src/main/x/logging/ConsoleLogSink.x | 79 ++++++++-- lib_logging/src/main/x/logging/LogEvent.x | 25 ++- lib_logging/src/main/x/logging/LogSink.x | 18 +++ lib_logging/src/main/x/logging/Logger.x | 13 ++ .../src/main/x/logging/LoggerFactory.x | 10 +- .../src/main/x/logging/LoggerRegistry.x | 51 ++++++ lib_logging/src/main/x/logging/MDC.x | 114 ++++++++++---- .../src/main/x/logging/MemoryLogSink.x | 30 +++- .../src/main/x/logging/MessageFormatter.x | 116 ++++++++++++-- lib_logging/src/main/x/logging/NoopLogSink.x | 10 +- .../src/test/x/LoggingTest/ListLogSink.x | 26 +++- lib_logging/src/test/x/LoggingTest/MDCTest.x | 134 ++++++++++++++++ .../src/test/x/LoggingTest/MarkerTest.x | 35 +++++ .../test/x/LoggingTest/MessageFormatterTest.x | 91 +++++++++++ .../src/test/x/LoggingTest/NamedLoggerTest.x | 98 ++++++++++++ .../x/LoggingTest/StructuredLoggingTest.x | 78 ++++++++++ manualTests/src/main/x/TestLogger.x | 99 +++++++++--- 27 files changed, 1489 insertions(+), 202 deletions(-) create mode 100644 lib_logging/src/main/x/logging/LoggerRegistry.x create mode 100644 lib_logging/src/test/x/LoggingTest/MDCTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/MessageFormatterTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/NamedLoggerTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/StructuredLoggingTest.x diff --git a/doc/logging/CUSTOM_SINKS.md b/doc/logging/CUSTOM_SINKS.md index 7d169a8d57..902c2dfdfd 100644 --- a/doc/logging/CUSTOM_SINKS.md +++ b/doc/logging/CUSTOM_SINKS.md @@ -24,6 +24,26 @@ just decide: read `event.marker`, `event.exception`, `event.mdcSnapshot`, and (once added) `event.keyValues`. +## `const` or `service`? + +`BasicLogger` is a `const` and holds its sink in a `LogSink` field. Ecstasy requires +that field to be `Passable`, so every implementation must be either `immutable` (a +`const`) or a `service`. Pick one of the two: + +- **`const`** — for stateless forwarders / pure adapters. Configuration is fixed at + construction (e.g. a `rootLevel` value passed in once). The sink owns no mutable state + shared across fibers. Examples in `lib_logging`: `NoopLogSink`, `ConsoleLogSink`. + Cheap to construct, cheap to pass; methods run on the caller's fiber. +- **`service`** — for sinks that genuinely have shared mutable state: an event buffer, + a counter map, an open file handle, a worker queue. The service mailbox handles + concurrent ingress for free. Examples: `MemoryLogSink`, `ListLogSink` (test sinks), + the `FileLogSink`/`HierarchicalLogSink`/`TeeLogSink` examples below. + +If you find yourself reaching for `synchronized` blocks, you want a `service`. If your +sink is "given a `Console`/`Writer`/`Function`, format and forward" — you want a +`const`. The full rule, with reference examples from the wider XDK / platform +ecosystem, is in `DESIGN.md` ("Sink type: `const` vs `service`"). + ## A worked example: a counting sink The simplest non-trivial sink — counts events per level. Useful for metrics export. diff --git a/doc/logging/DESIGN.md b/doc/logging/DESIGN.md index 279af2d6cc..d4260e8bb8 100644 --- a/doc/logging/DESIGN.md +++ b/doc/logging/DESIGN.md @@ -30,7 +30,7 @@ This is the same architecture the SLF4J full guide describes (`slf4j_full_guide. the repo root, section "Architecture") — it is a deliberate decision to mirror it because SLF4J got the layering right, and copying it gets us instant familiarity for free. -## Why named injection +## Why injection (and why per-name loggers come from `Logger.named(String)`) Ecstasy already routes `Console` through `@Inject Console console;` — see `lib_ecstasy/src/main/x/ecstasy/io/Console.x` and `javatools_jitbridge/.../TerminalConsole.java`. @@ -38,10 +38,20 @@ The runtime controls which `Console` impl gets wired up. Loggers fit the same sh the runtime decides where output goes (in v0, always `ConsoleLogSink`; later, possibly a configurable `LogbackLogSink`), and user code is sink-agnostic. -The injection key is `(TypeConstant, String name)`; for `@Inject Logger`, the empty/default -name resolves to the global default logger. For `@Inject("com.example") Logger`, the name -becomes the logger's name. See `OPEN_QUESTIONS.md` for the wildcard-name resolution -change required in `nMainInjector.supplierOf` — the existing `Resource` map is exact-match. +`@Inject Logger logger;` resolves to a single fixed-name root logger — exactly one +supplier (`("logger", loggerType)`) is registered with the injector. Per-name loggers +are *derived* via `Logger.named(String)` rather than acquired through additional +injection sites: + +```ecstasy +@Inject Logger logger; +static Logger PaymentLogger = logger.named("payments"); +``` + +This mirrors SLF4J's `LoggerFactory.getLogger(MyClass.class)` idiom one-to-one, and +keeps the injector's resource table as the single registry of allowed injections — +no type-only wildcard fallback, no special-case for `Logger`. See +`RUNTIME_IMPLEMENTATION_PLAN.md` Stage 1.4 for the full rationale. ## API surface @@ -103,18 +113,65 @@ Holds `(name, sink)`. Each emission method: ### `ConsoleLogSink` -Service holding `@Inject Console console;` and a `rootLevel` threshold (default `Info`). -Emits one line per event in a fixed format: timestamp, thread, padded level, logger name, -message, optional `[marker=NAME]` suffix; appends the exception on a following line. +`const` (not service) holding `@Inject Console console;` and a construct-time `rootLevel` +threshold (default `Info`). Emits one line per event in a fixed format: timestamp, thread, +padded level, logger name, message, optional `[marker=NAME]` suffix; appends the +exception on a following line. ### `NoopLogSink` -Drops everything. `isEnabled` returns False so callers that respect the check skip all -formatting work. Useful for libraries that want quiet defaults. +`const`. Drops everything. `isEnabled` returns False so callers that respect the check +skip all formatting work. Useful for libraries that want quiet defaults. ### `MemoryLogSink` -Captures events in an array. Test-only; not registered as a default. +`service` (mutable `events[]` shared across fibers). Captures events in an array. +Test-only; not registered as a default. + +## Sink type: `const` vs `service` + +`BasicLogger` is itself a `const`. Its `sink: LogSink` field therefore requires +implementations to be `Passable`, i.e. either `immutable` (a `const`) or a `service`. +The choice between the two is not arbitrary — it follows a clean rule that mirrors what +both the SLF4J/Logback ecosystem and the surrounding XDK codebases already do: + +| Property | use `const` | use `service` | +|---|---|---| +| Has its own mutable state shared across fibers? | no | yes | +| Aggregates events for later inspection? | no | yes | +| Owns external resources (writers, sockets, queues)? | no | yes | +| Pure forwarder over an injected service? | yes | — | +| Configuration fixed at construction? | yes | (typically yes either way) | + +Concretely, in this library: + +- `NoopLogSink` — `const` (stateless). +- `ConsoleLogSink` — `const` (forwarder over `@Inject Console`, threshold fixed at + construction). Structurally identical to + `lib_ecstasy/src/main/x/ecstasy/io/ConsoleAppender.x` and `ConsoleLog.x`, both `class`. +- `MemoryLogSink`, `ListLogSink` — `service` (collect `events[]`). +- A future `FileLogSink` owning a `Writer` — `service`. +- A future `AsyncLogSink` owning a worker queue — `service`. + +This matches the pattern used elsewhere in the XDK / platform ecosystem: + +- `service ErrorLog extends SimpleLog` — `platform/common/src/main/x/common.x:23`, + collects log entries from many callers. +- `service ConsoleExecutionListener implements ExecutionListener` — + `xvmrepo/lib_xunit_engine/.../console/ConsoleExecutionListener.x:4`, captures test + events into a private `Map`. **Structurally identical to `MemoryLogSink`.** +- `service HostManager` with `Map> activityHistogram` — + `platform/host/.../HostManager.x:40`. +- `class ConsoleAppender(Console console, ...)` — `lib_ecstasy/.../ConsoleAppender.x:4`, + no own mutable state. + +Mapping back to Logback: Logback collapses both shapes into one `Appender` interface +(everything is a Java class with a `synchronized log()` method). In Ecstasy the +service/const distinction makes the intent explicit at the type level — and is enforced +by the compiler whenever a sink is held by a `const` such as `BasicLogger`. + +Open question: is one-interface-with-two-allowed-impl-shapes the right call, or should +`LogSink` split into two interfaces? See `OPEN_QUESTIONS.md` (item 6). ## Future: `lib_logging_logback` @@ -143,11 +200,67 @@ We don't recommend this as the primary path — see `NATIVE_BRIDGE.md` for the f analysis — but it's a feasible escape hatch and worth documenting because it constrains the design. +## Per-container sink override + +Each Ecstasy container has its own injector. Host code that wants a nested +container to use a *different* sink than the host's default does so by +configuring the child container's injector explicitly — there is no +`lib_logging`-side helper for this, by design (see `OPEN_QUESTIONS.md` item 8). + +The pattern matches how every other injectable resource in the XDK is +overridden per child container: + +```ecstasy +import logging.Logger; +import logging.LogSink; +import logging.MemoryLogSink; + +// Inside host code that's about to spawn a child container: +LogSink childSink = new MemoryLogSink(); // or any other LogSink + +// When constructing the child container, pass an injector that resolves +// `("logger", Logger)` against `childSink` instead of the host's default. +// The exact host API depends on which container-construction helper the host +// uses; the principle is the same everywhere — replace the resource supplier +// for the `Logger` injection key. +``` + +In practice, host runtimes typically expose a `withResource(...)` / +`addResourceSupplier(...)` style API on the container's injector; the host +adds an entry `("logger", Logger) → new BasicLogger(name, childSink)` before +the child container starts running. The child container's `@Inject Logger` +resolves to that supplier; the host's loggers are unaffected. + +We deliberately do not ship a `ContainerLoggingConfig.set(child, sink)` +convenience because: + +1. The container/injector API is the right place for *all* per-container + resource overrides; every library inventing its own helper would create N + parallel ways to do the same thing. +2. Real-world usage of this is rare — most programs run with a single sink + per process. Adding a helper now would lock in an API for a use case we + haven't seen demand for yet. + +If a future user needs this often enough that a helper is worth it, the +helper lives in the host runtime's injector library, not here. + ## What isn't here yet -- The runtime-side injection wiring. The `BasicLogger` and the sink classes exist as - Ecstasy code, but `@Inject Logger` doesn't actually resolve to one until - `RTLogger.java` is added in `javatools_jitbridge` and registered in `nMainInjector`. -- The real `MessageFormatter`. Currently a stub that returns the message unchanged. -- Tests. None yet; the `manualTests/` sample is the next step. -- Compiler-side default name from module — open question. +- **Compiler-side default name from module** — `@Inject Logger logger;` (no + `resourceName`) currently gets the fixed-name root logger; users derive per-class + loggers via `logger.named("...")`. The XTC-compiler change to substitute the + enclosing module's qualified name is `RUNTIME_IMPLEMENTATION_PLAN.md` Stage 4 and + remains open. +- **`AsyncLogSink` / `lib_logging_logback` / native bridge** — see + `OPEN_QUESTIONS.md` items 7 (async) and the `LOGBACK_INTEGRATION.md` / + `NATIVE_BRIDGE.md` follower-module sketches. +- **Per-container override convenience** — open question 8. + +The runtime-side injection wiring lives in +`javatools/src/main/java/org/xvm/runtime/NativeContainer.java` (`ensureLogger`, +`ensureConst`); `BasicLogger` is the `const` returned for `@Inject Logger`. The +earlier interpose service `xRTLogger.java` was removed in favour of constructing +`BasicLogger` directly so MDC fiber-locals survive injection (see Q-D5 in +`OPEN_QUESTIONS.md`). The real `MessageFormatter` is implemented (12 tests in +`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (51 +passing as of this commit). diff --git a/doc/logging/INJECTED_LOGGER_EXAMPLE.md b/doc/logging/INJECTED_LOGGER_EXAMPLE.md index c6f963d0f4..f4cd11d051 100644 --- a/doc/logging/INJECTED_LOGGER_EXAMPLE.md +++ b/doc/logging/INJECTED_LOGGER_EXAMPLE.md @@ -7,32 +7,62 @@ look like in real code?" The accompanying executable sample lives at [`manualTests/src/main/x/TestLogger.x`](../../manualTests/src/main/x/TestLogger.x). -> **Status reminder.** The runtime injection plumbing (the native side that resolves -> `@Inject Logger logger;` to a real `BasicLogger` instance) is not yet wired up — see -> `OPEN_QUESTIONS.md`. The example below is the shape user code is *meant* to take. -> Until the runtime is wired, equivalent behaviour can be obtained by constructing -> `BasicLogger` directly against any `LogSink`, exactly as the unit tests under -> `lib_logging/src/test/x/LoggingTest/` do. +> **Status (2026-05).** Runtime injection of `@Inject Logger logger;` is wired in +> the interpreter — `NativeContainer.ensureLogger` constructs a `BasicLogger` +> directly. There is no longer a separate `xRTLogger` / `_native.logging.RTLogger` +> service wrapper; collapsing it was needed so per-fiber `MDC` survives injection. +> The JIT injector still needs the equivalent wiring; until it does, `--jit` runs +> won't resolve the injection. + +## API at a glance + +There are exactly two acquisition shapes: + +```ecstasy +@Inject Logger logger; // root logger via injection +Logger payments = logger.named("payments"); // per-name child via the API +``` + +There is **no** `@Inject("payments") Logger logger;` form. Per-name loggers are +*derived* from the injected one, the same way SLF4J users write +`LoggerFactory.getLogger(MyClass.class)` once at the top of a class. See +`RUNTIME_IMPLEMENTATION_PLAN.md` Stage 1.4 for why we ruled out wildcard injection. + +The examples below `import` the public lib_logging types unqualified by adding +type-level imports next to the `package log import …` alias: + +```ecstasy +package log import logging.xtclang.org; +import log.Logger; +import log.MarkerFactory; +import log.MDC; +import log.Marker; +``` + +You can also keep the `log.` prefix everywhere — both forms compile — but unqualified +reads better in real code, and that's the form below. ## The smallest possible app ```ecstasy module HelloLogging { package log import logging.xtclang.org; + import log.Logger; void run() { - @Inject log.Logger logger; + @Inject Logger logger; logger.info("hello, {}", ["world"]); } } ``` That's it. There is no factory call, no static initializer, no configuration file. The -runtime container hands the application a `Logger`; the default sink (`ConsoleLogSink`) -emits the line to the platform `Console`. Output: +runtime container hands the application a `Logger` (named `"logger"` for now — see +Stage 4 of the runtime plan for the optional compiler-side default-name change); the +default sink (`ConsoleLogSink`) emits the line to the platform `Console`. Output: ``` -2026-04-29T11:23:45.012Z [main] INFO HelloLogging: hello, world +2026-04-29T11:23:45.012Z [] INFO logger: hello, world ``` ## A more realistic app @@ -48,12 +78,16 @@ The example below is closer to what real code looks like. It demonstrates: ```ecstasy module PaymentService { package log import logging.xtclang.org; + import log.Logger; + import log.MarkerFactory; + import log.MDC; + import log.Marker; - @Inject("PaymentService") log.Logger logger; - @Inject log.MarkerFactory markers; - @Inject log.MDC mdc; + @Inject Logger logger; + @Inject MarkerFactory markers; + @Inject MDC mdc; - log.Marker AUDIT = markers.getMarker("AUDIT"); + Marker audit = markers.getMarker("AUDIT"); void run() { processPayment("p_123", 4200, "EUR", "user_42"); @@ -72,7 +106,7 @@ module PaymentService { // Structured + categorical: tag with AUDIT, attach typed KV pairs. logger.atInfo() - .addMarker(AUDIT) + .addMarker(audit) .addKeyValue("amount", amount) .addKeyValue("currency", currency) .log("payment completed"); @@ -114,15 +148,20 @@ constructed at all. ## Scaling up — multiple loggers, one per module/class -The SLF4J convention is one logger per class, named by the class. In Ecstasy the -equivalent is one named injection per logical scope: +In a typical app each scope just has its own `logger` — inject once, use directly. +You only reach for `Logger.named(String)` when you need the *same* enclosing scope to +emit under multiple names. The most common case is one logger per class within a +module: ```ecstasy module BillingService { package log import logging.xtclang.org; + import log.Logger; + + @Inject Logger logger; // injected once for the whole module service Invoicer { - @Inject("billing.Invoicer") log.Logger logger; + Logger logger = BillingService.logger.named("billing.Invoicer"); void issue(Invoice inv) { logger.info("issuing invoice {}", [inv.id]); @@ -131,7 +170,7 @@ module BillingService { } service Charger { - @Inject("billing.Charger") log.Logger logger; + Logger logger = BillingService.logger.named("billing.Charger"); void charge(Invoice inv) { logger.info("charging {} for {}", [inv.customer, inv.total]); @@ -141,8 +180,11 @@ module BillingService { } ``` -Two loggers, two names. A configuration-driven sink (`lib_logging_logback`, future) can -set `billing.Charger` to `Debug` while keeping `billing.Invoicer` at `Info`. +Each service holds its own `logger` field — call sites still read `logger.info(...)`, +no rename. Both share the injected one's sink, so a configuration-driven sink +(`lib_logging_logback`, future) can set `billing.Charger` to `Debug` while keeping +`billing.Invoicer` at `Info`. This matches SLF4J's `LoggerFactory.getLogger(MyClass)` +idiom one-to-one. ## Without injection — `LoggerFactory` @@ -163,42 +205,43 @@ module Util { `LoggerFactory.getLogger` is itself a service that consults an injected default `LogSink`, so the per-container override property still holds. -## Without the runtime wiring (today's reality) +## Without injection — explicit construction -Until the runtime side resolves `@Inject Logger`, you can do the same thing by hand: +Anywhere injection isn't in scope (static initialisers, unit-test fixtures), construct +the logger by hand exactly like the unit tests do: ```ecstasy module Today { package log import logging.xtclang.org; + import log.BasicLogger; + import log.ConsoleLogSink; + import log.Logger; + import log.LogSink; void run() { - @Inject Console console; - log.LogSink sink = new log.ConsoleLogSink(); - log.Logger logger = new log.BasicLogger("Today", sink); + LogSink sink = new ConsoleLogSink(); + Logger logger = new BasicLogger("Today", sink); logger.info("hello, {}", ["world"]); } } ``` -This is exactly what the unit tests in `lib_logging/src/test/x/LoggingTest/` do, and -exactly what the manualTest does today. Once `RTLogger.java` lands in -`javatools_jitbridge` and is registered in `nMainInjector`, the explicit construction -becomes unnecessary and the code in the earlier sections is what callers will actually -write. +`LoggerFactory.getLogger(...)` is also available for the static-init case. ## Cheat sheet | You want to… | Write | |---|---| +| Inject the root logger | `@Inject Logger logger;` | +| Get a per-name logger | `Logger payments = logger.named("payments");` | | Log info | `logger.info("msg")` | | Log info with args | `logger.info("msg {}", [arg])` | | Log info with exception | `logger.info("msg", cause=e)` | | Log info with marker | `logger.info("msg", marker=AUDIT)` | | Cheap level check | `if (logger.infoEnabled) { ... }` | | Fluent builder | `logger.atInfo().addMarker(AUDIT).log("msg")` | -| Get a logger by name | `@Inject("foo") Logger logger;` | -| Get a logger by class | `LoggerFactory.getLogger(MyClass)` | +| Get a logger by name (no injection) | `LoggerFactory.getLogger("foo")` | | Attach context for this request | `mdc.put("requestId", id);` | That covers ~95% of real-world logging use. diff --git a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md b/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md index b411d494eb..b5b7273080 100644 --- a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md +++ b/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md @@ -1,5 +1,18 @@ # Runtime implementation plan — getting to a demo-worthy round trip +> **Status (2026-05): Stages 1–3 are landed.** The runtime now resolves +> `@Inject Logger logger;` end-to-end; the demo described in §"Definition of +> 'demo worthy'" works as written. **The plan diverged from this document in one +> way worth flagging up front:** there is no separate `RTLogger.java` / +> `xRTLogger`. `BasicLogger` is a `const`, and the runtime constructs it +> directly in `javatools/.../NativeContainer.java` (`ensureLogger` / +> `ensureConst`). The reason — collapsing the service-wrapper indirection so +> per-fiber `MDC` (`SharedContext`) survives injection — is documented as +> question Q-D5 in `OPEN_QUESTIONS.md`. The stage descriptions below describe +> the *original* approach for historical context; treat them as background, not +> as instructions for current work. Stage 4 (compiler-side default name) +> remains open. + This document is the actionable plan that turns the v0 stub (`@Inject Logger logger;` parses but does not resolve) into a **working end-to-end demo**: the Ecstasy line @@ -32,8 +45,10 @@ The round trip is demo-worthy when **all** of these are true: compiles, runs, and prints a single timestamped line containing `hello world` without any explicit `BasicLogger` / `LogSink` wiring. -2. The same demo with `@Inject("com.example") log.Logger logger;` resolves to a - logger named `com.example` (output line carries the name). +2. The same demo with `Logger demo = logger.named("com.example"); demo.info(...);` + produces a logger named `com.example` (output line carries the name). Per-name + loggers are derived from the injected one, not injected directly — see Stage 1.4 + for why `@Inject("…") Logger` is *not* the chosen API shape. 3. `logger.error("failed: {}", [id], cause=new Exception("boom"));` emits both the message line and the exception text. @@ -68,9 +83,10 @@ Three native files in `javatools_jitbridge/`, plus a registration line in `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogger.java`. -A native service that wraps an Ecstasy `BasicLogger` instance. The `$create(Object -opts)` factory receives the resource name (the string passed in `@Inject("foo") -Logger`) and bakes it into the constructed logger: +A native service that wraps an Ecstasy `BasicLogger` instance. There is exactly one +registration: `(loggerType, "logger")`. The `$create(Object opts)` factory always +constructs the root logger named `"logger"`; per-name children are obtained from it +via `Logger.named(String)` (see Stage 1.4 for why we don't accept `@Inject("…")`). - Constructor takes `(String name, LogSink sink)`. The `sink` argument resolves via the same injector — this is the second native registration below. @@ -94,36 +110,90 @@ Same pattern, smaller. Both wrap their Ecstasy counterparts. `RTMDC` needs a per storage decision — see Stage 2 below; until that's resolved, it holds a per-instance `HashMap` and we accept that MDC is per-container, not per-fiber. -#### 1.4 — Wildcard-name resolution in `nMainInjector` +#### 1.4 — Single fixed-name supplier; no wildcard injection + +**Resolved against wildcard.** The earlier draft of this plan called for a wildcard +`(loggerType, "*")` fallback so that `@Inject("com.example") Logger logger;` would +resolve to a per-name logger. That shape was prototyped in `NativeContainer` (interpreter +side) and then deliberately removed. The chosen design instead: + +- Register exactly one supplier in `NativeContainer.initResources` / + `nMainInjector.addNativeResources`: `("logger", loggerType)` → root `Logger`. +- Expose `Logger.named(String)` on the public API. Per-name loggers are *derived*, not + injected: `@Inject Logger logger; Logger payments = logger.named("payments");`. +- `getInjectable` stays untouched. There is no type-only special-case in the runtime; + the supplier table remains the single registry of allowed injections. + +This is the same call-site shape SLF4J users already write in Java +(`LoggerFactory.getLogger(MyClass.class)`), so it does not cost ergonomics relative to +the SLF4J baseline. It does cost the spelling `@Inject("name") Logger logger;`, which +has no actual SLF4J equivalent and was a `lib_logging` invention. + +##### Why we rejected wildcard injection + +1. **Special-case at the deepest layer of the runtime.** A type-only fallback inside + `NativeContainer.getInjectable` (or `nMainInjector.supplierOf`) means the supplier + table no longer answers "what can be injected here?" without consulting a hidden + per-type bypass. +2. **One customer.** The whole wildcard mechanism was being built for `Logger`. A + feature that special-cases the runtime for a single library type is hard to justify. +3. **Imagined SLF4J parity.** SLF4J in Java does not use parameterized injection at all + — its idiom is `LoggerFactory.getLogger(MyClass.class)`. The `@Inject("name")` form + was a `lib_logging` invention pitched as "SLF4J ergonomics" but is in fact a *more* + concise spelling than SLF4J actually offers. The cost-vs-benefit didn't pencil out. +4. **The "future generalisation" argument has no second customer.** Promoting wildcard + to a first-class `Injector` API only pays off if a second library wants type-only + injection. None is in sight. + +##### Cost of the chosen design + +One extra line per class that wants a per-name logger: + +```ecstasy +@Inject Logger logger; // injected once +static Logger PaymentLogger = logger.named("payments"); // derived +``` -`javatools_jitbridge/.../mgmt/nMainInjector.java`. Today `supplierOf(Resource)` does -exact `(TypeConstant, String)` lookup. Modify to: +That line is what every Java SLF4J user writes today. We accept it. -- First try the exact key. -- If miss and the type is `Logger`, fall back to a wildcard entry registered as - `(loggerType, "*")`. The factory receives the requested resource name as `opts` and - produces a logger of that name. +##### Alternatives that were considered and rejected -Eight or ten lines of new Java. Opts to make it generic over types if the same need -arises elsewhere, but for v0 a `Logger`-specific fallback is fine. +- **`@Inject(opts="com.example") Logger logger;`** — spelling is awkward and still + routes a single-supplier lookup with the real identity hidden in `opts`. +- **Compiler-substituted module name** (Stage 4) — useful *complement* (auto-defaults + the per-module logger name), but doesn't address per-class loggers within a module. + Track separately if it lands. +- **Pure `LoggerFactory.getLogger(...)` with no `@Inject Logger` at all** — gives up + the injection ergonomic without buying anything; the per-name problem just moves + one level out. #### 1.5 — Registration in `addNativeResources()` -The single-line additions: +The interpreter-side equivalent is already in place: + +```java +xRTLogger templateLogger = xRTLogger.INSTANCE; +TypeConstant typeLogger = templateLogger.getCanonicalType(); +addResourceSupplier(new InjectionKey("logger", typeLogger), + (frame, hOpts) -> templateLogger.ensureLogger(frame, "logger", hOpts)); +``` + +For the JIT injector, the analogous addition: ```java -suppliers.put(new Resource(loggerType, "*"), RTLogger::$create); +suppliers.put(new Resource(loggerType, "logger"), RTLogger::$create); suppliers.put(new Resource(logSinkType, "default"), RTConsoleLogSink::$create); suppliers.put(new Resource(markerFactoryType, "markers"), RTMarkerFactory::$create); suppliers.put(new Resource(mdcType, "mdc"), RTMDC::$create); ``` `loggerType` etc. are resolved via the existing TypeConstant lookup machinery the same -way `consoleType` is. +way `consoleType` is. Note the `(loggerType, "logger")` exact-name entry — no wildcard. -**Done state of Stage 1:** `@Inject Logger logger;` returns a working `Logger` -instance. Tests still pass. The `runInjected()` path of `manualTests/.../TestLogger.x` -runs without throwing. +**Done state of Stage 1:** `@Inject Logger logger;` returns a working root `Logger` +instance. `Logger.named(String)` derives per-name children. Tests pass. The +`runInjected()` and `runInjectedByName()` paths of `manualTests/.../TestLogger.x` both +print to the console. ### Stage 2 — Make the message actually format @@ -191,9 +261,10 @@ non-empty. ### Stage 4 — Compiler-side default name (optional but high-impact) -Today `@Inject Logger logger;` (no `resourceName`) resolves to a logger named -`"default"`. Every SLF4J user instinctively expects the logger to be named after the -enclosing module/class. The fix is a small XTC compiler change. +Today `@Inject Logger logger;` (no `resourceName`) resolves to the root logger +literally named `"logger"` — the field-name fallback. Many SLF4J users find the +enclosing module/class name more useful as the default. The fix is a small XTC +compiler change. #### 4.1 — Substitute the enclosing module name @@ -210,9 +281,9 @@ naming behaviour. emits lines tagged `PaymentService:`. The cheat sheet in `INJECTED_LOGGER_EXAMPLE.md` becomes accurate without `@Inject("PaymentService")`. -This stage is **optional for the demo**. Without it, the demo writes -`@Inject("PaymentService") Logger logger;` explicitly. With it, the demo is one line -shorter and matches SLF4J muscle memory. +This stage is **optional for the demo**. Without it, callers write +`@Inject Logger logger; Logger paymentLogger = logger.named("PaymentService");`. With +it, `@Inject Logger logger;` alone is already named after the enclosing module. ### Stage 5 — Validation: platform repo migration diff --git a/doc/logging/SLF4J_PARITY.md b/doc/logging/SLF4J_PARITY.md index 127163f607..a90add2c08 100644 --- a/doc/logging/SLF4J_PARITY.md +++ b/doc/logging/SLF4J_PARITY.md @@ -8,10 +8,14 @@ scan this once and know everything they need. | SLF4J 2.x (Java) | lib_logging (Ecstasy) | |---|---| -| `LoggerFactory.getLogger(MyClass.class)` | `LoggerFactory.getLogger(MyClass)` | -| `LoggerFactory.getLogger("com.example.foo")` | `LoggerFactory.getLogger("com.example.foo")` | -| (no equivalent) | `@Inject Logger logger;` | -| (no equivalent) | `@Inject("com.example.foo") Logger logger;` | +| `LoggerFactory.getLogger(MyClass.class)` | `@Inject Logger logger;` `static Logger LOG = logger.named(MyClass.qualifiedName);` | +| `LoggerFactory.getLogger("com.example.foo")` | `LoggerFactory.getLogger("com.example.foo")` *(also)* `logger.named("com.example.foo")` | +| (no equivalent) | `@Inject Logger logger;` *(injects a single root logger)* | + +`@Inject("com.example.foo") Logger logger;` was considered and rejected — see +`RUNTIME_IMPLEMENTATION_PLAN.md` Stage 1.4 for why. SLF4J doesn't have a +parameterized injection annotation either; `LoggerFactory.getLogger(MyClass.class)` +is its idiom and `Logger.named(String)` is the direct Ecstasy equivalent. ## `Logger` core methods diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index d3f41633a5..ac04746c5e 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -373,6 +373,89 @@ private void initResources(ConstantPool pool) { xBasicHashCollector templateHashCollector = xBasicHashCollector.INSTANCE; TypeConstant typeHashCollector = templateHashCollector.getCanonicalType(); addResourceSupplier(new InjectionKey("hash", typeHashCollector), templateHashCollector::ensureCollector); + + // +++ logging.Logger and logging.MDC + // Both back `@Inject Logger logger;` and `@Inject MDC mdc;` on the user side. They + // are both `const` types in `lib_logging`, deliberately NOT services: a service + // wrapper around `Logger` would create a new fiber per call, and the [MDC] tokens + // (registered via `SharedContext.withValue` on the caller's fiber) would not be + // visible to `BasicLogger.emit` running on the wrapper's fiber. Constructing the + // `const` directly hands the user a Passable handle whose methods execute on their + // own fiber, preserving fiber-local MDC propagation end-to-end. + // + // The supplier shape is the same as for any other native-injected resource: one + // `(typeName, resourceName)` registration, no wildcard branch in `getInjectable`. + ModuleConstant modLogging = pool.ensureModuleConstant("logging.xtclang.org"); + // The injection key uses the user-facing interface type (`Logger`) — that's what + // `@Inject Logger logger;` resolves against. The supplier internally constructs a + // `BasicLogger` (the canonical implementation, a `const`) using that as the + // typeLogger argument to `findConstructor`. + TypeConstant typeLoggerIf = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modLogging, "Logger")); + TypeConstant typeLogger = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modLogging, "BasicLogger")); + addResourceSupplier(new InjectionKey("logger", typeLoggerIf), + (frame, hOpts) -> ensureLogger(frame, typeLogger, "logger")); + + TypeConstant typeMDC = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modLogging, "MDC")); + addResourceSupplier(new InjectionKey("mdc", typeMDC), + (frame, hOpts) -> ensureConst(frame, typeMDC)); + } + + /** + * Construct an instance of `logging.xtclang.org.BasicLogger` named `name`. The + * `BasicLogger(String)` convenience constructor wires a fresh `ConsoleLogSink`, so the + * native side does not have to thread a sink handle through. + */ + private ObjectHandle ensureLogger(Frame frame, TypeConstant typeLogger, String name) { + ClassTemplate template = getTemplate(typeLogger.getSingleUnderlyingClass(true)); + ConstantPool pool = getConstantPool(); + MethodStructure ctor = template.getStructure().findConstructor(pool.typeString()); + ClassComposition clz = template.getCanonicalClass(); + + ObjectHandle[] ahArgs = new ObjectHandle[ctor.getMaxVars()]; + ahArgs[0] = xString.makeHandle(name); + + switch (template.construct(frame, ctor, clz, null, ahArgs, Op.A_STACK)) { + case Op.R_NEXT: + return frame.popStack(); + + case Op.R_CALL: + return new DeferredCallHandle(frame.m_frameNext); + + case Op.R_EXCEPTION: + return new DeferredCallHandle(frame.clearException()); + + default: + throw new IllegalStateException(); + } + } + + /** + * Construct an instance of a parameter-less `const` from the lib_logging module via its + * default constructor. Used by the [MDC] supplier; suitable for any other future + * lib_logging const that wants the same default-construction pattern. + */ + private ObjectHandle ensureConst(Frame frame, TypeConstant typeConst) { + ClassTemplate template = getTemplate(typeConst.getSingleUnderlyingClass(true)); + MethodStructure ctor = template.getStructure().findConstructor(); + ClassComposition clz = template.getCanonicalClass(); + + switch (template.construct(frame, ctor, clz, null, + new ObjectHandle[ctor.getMaxVars()], Op.A_STACK)) { + case Op.R_NEXT: + return frame.popStack(); + + case Op.R_CALL: + return new DeferredCallHandle(frame.m_frameNext); + + case Op.R_EXCEPTION: + return new DeferredCallHandle(frame.clearException()); + + default: + throw new IllegalStateException(); + } } /** @@ -728,6 +811,7 @@ public FileStructure createFileStructure(ModuleStructure moduleApp) { // TODO CP/GG: that needs to be reworked (for now the order is critical) fileApp.merge(m_moduleTurtle, false, false); fileApp.merge(f_repository.loadModule("crypto.xtclang.org"), true, false); + fileApp.merge(f_repository.loadModule("logging.xtclang.org"), true, false); fileApp.merge(f_repository.loadModule("net.xtclang.org"), true, false); fileApp.merge(f_repository.loadModule("web.xtclang.org"), true, false); fileApp.merge(m_moduleNative, false, false); diff --git a/javatools_bridge/build.gradle.kts b/javatools_bridge/build.gradle.kts index 9045d56df2..b51c5191b9 100644 --- a/javatools_bridge/build.gradle.kts +++ b/javatools_bridge/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { xtcModule(libs.xdk.convert) xtcModule(libs.xdk.crypto) xtcModule(libs.xdk.json) + xtcModule(libs.xdk.logging) xtcModule(libs.xdk.net) xtcModule(libs.xdk.sec) xtcModule(libs.xdk.web) diff --git a/javatools_bridge/src/main/x/_native.x b/javatools_bridge/src/main/x/_native.x index 95423b58ae..46bebfccc0 100644 --- a/javatools_bridge/src/main/x/_native.x +++ b/javatools_bridge/src/main/x/_native.x @@ -6,8 +6,9 @@ * It is an error for any type or class from this module to be visible to user code. */ module _native.xtclang.org { - package libcolls import collections.xtclang.org; - package libcrypto import crypto.xtclang.org; - package libnet import net.xtclang.org; - package libweb import web.xtclang.org; -} \ No newline at end of file + package libcolls import collections.xtclang.org; + package libcrypto import crypto.xtclang.org; + package liblogging import logging.xtclang.org; + package libnet import net.xtclang.org; + package libweb import web.xtclang.org; +} diff --git a/lib_logging/src/main/x/logging/BasicEventBuilder.x b/lib_logging/src/main/x/logging/BasicEventBuilder.x index da8bbcb487..ee78069279 100644 --- a/lib_logging/src/main/x/logging/BasicEventBuilder.x +++ b/lib_logging/src/main/x/logging/BasicEventBuilder.x @@ -6,13 +6,14 @@ * Default `LoggingEventBuilder` implementation. Accumulates state then forwards to the * underlying `Logger`'s `log(...)` method. */ -class BasicEventBuilder(Logger logger, Level level) +class BasicEventBuilder(BasicLogger logger, Level level) implements LoggingEventBuilder { - private String? message = Null; - private Object[] args = new Object[]; - private Marker? marker = Null; - private Exception? cause = Null; + private String? message = Null; + private Object[] args = new Object[]; + private Marker[] markers = new Marker[]; + private Exception? cause = Null; + private Map keyValues = new ListMap(); @Override LoggingEventBuilder setMessage(String message) { @@ -28,9 +29,7 @@ class BasicEventBuilder(Logger logger, Level level) @Override LoggingEventBuilder addMarker(Marker marker) { - // TODO(api): SLF4J supports multiple markers per event. For now we keep the most - // recently added one; document this until the data model is decided. - this.marker = marker; + markers.add(marker); return this; } @@ -42,34 +41,33 @@ class BasicEventBuilder(Logger logger, Level level) @Override LoggingEventBuilder addKeyValue(String key, Object value) { - // TODO(api): structured KV requires a per-event map; sinks ignoring KV must still - // accept the call. Wire through to LogEvent in a future iteration. + keyValues.put(key, value); return this; } @Override void log() { if (String m ?= message) { - logger.log(level, m, frozen(args), cause, marker); + logger.emitWith(level, m, frozen(args), cause, frozenMarkers(), frozenKVs()); } } @Override void log(String message) { - logger.log(level, message, frozen(args), cause, marker); + logger.emitWith(level, message, frozen(args), cause, frozenMarkers(), frozenKVs()); } @Override void log(String format, Object arg) { args.add(arg); - logger.log(level, format, frozen(args), cause, marker); + logger.emitWith(level, format, frozen(args), cause, frozenMarkers(), frozenKVs()); } @Override void log(String format, Object arg1, Object arg2) { args.add(arg1); args.add(arg2); - logger.log(level, format, frozen(args), cause, marker); + logger.emitWith(level, format, frozen(args), cause, frozenMarkers(), frozenKVs()); } @Override @@ -77,7 +75,39 @@ class BasicEventBuilder(Logger logger, Level level) for (Object arg : args) { this.args.add(arg); } - logger.log(level, format, frozen(this.args), cause, marker); + logger.emitWith(level, format, frozen(this.args), cause, frozenMarkers(), frozenKVs()); + } + + /** + * Materialise the accumulated markers as an immutable, freezing each marker so it can + * cross the sink service boundary. Empty case returns the canonical empty constant. + */ + private Marker[] frozenMarkers() { + if (markers.empty) { + return []; + } + Marker[] frozen = new Array(markers.size); + for (Marker m : markers) { + frozen.add(m.freeze()); + } + return frozen.toArray(Constant); + } + + /** + * Materialise the accumulated structured key/value map as a const-mode (immutable) map + * so the resulting `LogEvent` can cross the sink boundary. SLF4J's reference impl + * realises a `KeyValuePair` list at the same point; here it's a `Map` on + * `LogEvent.keyValues`. + */ + private Map frozenKVs() { + if (keyValues.empty) { + return []; + } + ListMap snapshot = new ListMap(); + for ((String k, Object v) : keyValues) { + snapshot.put(k, v); + } + return snapshot.makeImmutable(); } /** diff --git a/lib_logging/src/main/x/logging/BasicLogger.x b/lib_logging/src/main/x/logging/BasicLogger.x index 4cf4de5c76..6eda98680d 100644 --- a/lib_logging/src/main/x/logging/BasicLogger.x +++ b/lib_logging/src/main/x/logging/BasicLogger.x @@ -9,17 +9,42 @@ * Holds only the logger's name and a reference to the active sink. All level checks, formatting, * MDC capture, and event construction happens here so that sinks themselves remain dumb. * + * `BasicLogger` is a `const`, not a `class` or `service`. Method calls therefore execute on + * the caller's fiber, which is essential for [MDC]: a service wrapper would create a new + * fiber per call, and `mdc.copyOfContextMap` invoked from inside that fiber would not see + * tokens registered by the caller. Being a `const` also lets the runtime hand a + * `BasicLogger` straight back from `@Inject Logger logger;` — no bridge service is needed. + * * User code does not normally instantiate this directly; it is what the runtime hands you in * response to `@Inject Logger logger;` and what `LoggerFactory.getLogger` returns. */ -class BasicLogger(String name, LogSink sink) +const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) implements Logger { + /** + * Convenience: name only. Wires a fresh [ConsoleLogSink] and no registry. This is + * the constructor the runtime calls for `@Inject Logger logger;`. + */ + construct(String name) { + construct BasicLogger(name, new ConsoleLogSink(), Null); + } + + /** + * Convenience: `(name, sink)`. No registry — `named(child)` allocates fresh on each + * call. Used by tests and ad-hoc construction. + * + * The convenience forms are explicit (rather than `registry = Null` defaults on the + * primary constructor) because cross-module default-argument resolution on `const` + * constructors does not always synthesise the shorter form: callers from a + * different module hit "Unresolvable function `construct(String, LogSink)`" + * otherwise. + */ + construct(String name, LogSink sink) { + construct BasicLogger(name, sink, Null); + } + @Inject Clock clock; - // MDC is intentionally not injected here in v0: the runtime side does not yet - // register the MDC resource, and tests construct loggers directly. Once - // `nMainInjector` registers MDC, replace the empty-map snapshot below with - // `mdc.copyOfContextMap`. See doc/logging/OPEN_QUESTIONS.md (entry 3). + @Inject MDC mdc; @Override @RO Boolean traceEnabled.get() = sink.isEnabled(name, Trace); @@ -37,6 +62,19 @@ class BasicLogger(String name, LogSink sink) return sink.isEnabled(name, level, marker); } + @Override + Logger named(String name) { + // `named` is a *replacement*, not a concatenation: the caller supplies the full + // logger name they want. This matches how SLF4J users typically write + // `LoggerFactory.getLogger("com.example.PaymentService")` and the existing + // `NamedLoggerTest` contract. With a registry attached, repeated calls with the + // same name return the same instance. + if (LoggerRegistry r ?= registry) { + return r.ensure(name); + } + return new BasicLogger(name, sink); + } + @Override void trace(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { emit(Trace, message, arguments, cause, marker); @@ -83,9 +121,51 @@ class BasicLogger(String name, LogSink sink) /** * Construct an event and hand it to the sink. The fast-path level check is the first thing * that happens — when disabled, no formatting work is done. + * + * The single-marker entry point used by per-level methods (`info`/`warn`/...) wraps the + * optional `marker` into a `Marker[]` so the rest of the pipeline only deals with one + * shape. Multi-marker events come through [BasicEventBuilder], which calls [emitWith] + * directly with an already-frozen `Marker[]`. */ private void emit(Level level, String message, Object[] arguments, Exception? cause, Marker? marker) { - if (!sink.isEnabled(name, level, marker)) { + Marker[] markers = []; + if (Marker m ?= marker) { + // See the freeze rationale on [emitWith]. + markers = [m.freeze()]; + } + emitWith(level, message, arguments, cause, markers, []); + } + + /** + * Internal entry point used by [BasicEventBuilder] to deliver an event that may carry + * structured key/value pairs and/or multiple markers. Per-level methods on `Logger` + * funnel through [emit] which wraps a single optional marker into a one-element array + * before calling here, so this is the one method that sees the full multi-marker shape. + * + * Service-boundary semantics: Ecstasy only lets immutable values, `const` values, + * service references, or already-frozen `Freezable`s cross from one service to + * another. `BasicMarker` is a mutable class (it accumulates child references via + * `add`), so handing a caller-supplied marker straight to a sink — when the sink IS + * a service (e.g. `service MemoryLogSink`) — fails with "mutable object cannot be + * used as an argument to a service call". The two callers ([emit] above and + * [BasicEventBuilder.frozenMarkers]) are both responsible for freezing their markers + * before they reach this method, so we can pass `markers` to the sink as-is. The + * sink-side `isEnabled` fast path therefore sees frozen markers too. + */ + void emitWith(Level level, String message, Object[] arguments, Exception? cause, + Marker[] markers, Map keyValues) { + // v0 policy on `arguments`: no defensive copy. Per `OPEN_QUESTIONS.md` Q11 the + // caller is contractually required not to mutate the array between the return of + // `info(...)` and any (possibly async) sink consuming the resulting `LogEvent`. + // Matches SLF4J's posture; `BasicEventBuilder` already freezes its accumulating + // `args` before calling here, so the fluent path is safe by construction. + // Reconsider if/when async sinks become a default. + + // The SPI's level check is single-marker (the first one is treated as "primary"), + // matching SLF4J 1.x's `Logger.isEnabledFor(Marker)`. Sinks that want richer + // multi-marker filtering inspect `event.markers` directly inside `log()`. + Marker? primary = markers.empty ? Null : markers[0]; + if (!sink.isEnabled(name, level, primary)) { return; } (String formatted, Exception? promoted) = MessageFormatter.format(message, arguments); @@ -95,11 +175,12 @@ class BasicLogger(String name, LogSink sink) level = level, message = formatted, timestamp = clock.now, - marker = marker, + markers = markers, exception = finalCause, arguments = arguments, - mdcSnapshot = new HashMap(), + mdcSnapshot = mdc.copyOfContextMap, threadName = "", // TODO(runtime): expose current-fiber identity + keyValues = keyValues, )); } diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x index 167c908a8a..0745735e18 100644 --- a/lib_logging/src/main/x/logging/ConsoleLogSink.x +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -12,28 +12,41 @@ * * If the event has an exception, the stack trace is appended on subsequent lines. * + * # Why this sink is a `const`, not a `service` + * + * `ConsoleLogSink` carries no mutable state of its own. Its threshold (`rootLevel`) is + * fixed at construction; every other piece of "state" is either an injected service + * reference (`@Inject Console console`) or constructed transiently inside `log()`. + * + * That makes it a forwarder, not an event collector — structurally identical to + * `lib_ecstasy/src/main/x/ecstasy/io/ConsoleAppender.x` and `ConsoleLog.x`, both of + * which are `class`/`const`-shaped wrappers over an injected `Console`. + * + * Sinks that *do* hold mutable shared state (e.g. `MemoryLogSink` collecting events, + * a future `FileLogSink` owning a `Writer`, a future `AsyncLogSink` owning a worker + * queue) must remain `service`. The rule of thumb is documented in + * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + * * Configuration is intentionally minimal: a single `rootLevel` threshold applied to every * logger. Per-logger / per-marker filtering is the job of richer sinks (see - * `docs/LOGBACK_INTEGRATION.md`). + * `doc/logging/LOGBACK_INTEGRATION.md`). */ -service ConsoleLogSink +const ConsoleLogSink(Level rootLevel) implements LogSink { - @Inject Console console; - /** - * The threshold below which events are dropped. Defaults to `Info` so production - * code is not flooded with debug output by accident. + * No-arg convenience: `new ConsoleLogSink()` resolves to threshold `Info`. Explicit + * (rather than `Level rootLevel = Info` default on the primary constructor) because + * cross-module default-argument resolution on `const` constructors does not always + * synthesise the zero-arg form — callers from a different module otherwise hit + * "Unresolvable function `construct()`." */ - public/private Level rootLevel = Info; - - /** - * Adjust the root level at runtime. - */ - void setRootLevel(Level level) { - rootLevel = level; + construct() { + construct ConsoleLogSink(Info); } + @Inject Console console; + @Override Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { return level.severity >= rootLevel.severity; @@ -50,8 +63,44 @@ service ConsoleLogSink .append(": ") .append(event.message); - if (Marker m ?= event.marker) { - buf.append(" [marker=").append(m.name).append(']'); + if (!event.markers.empty) { + buf.append(" [marker"); + buf.append(event.markers.size == 1 ? "=" : "s="); + Boolean firstMarker = True; + for (Marker m : event.markers) { + if (!firstMarker) { + buf.append(','); + } + firstMarker = False; + buf.append(m.name); + } + buf.append(']'); + } + + if (!event.mdcSnapshot.empty) { + buf.append(" [mdc="); + Boolean firstMdc = True; + for ((String k, String v) : event.mdcSnapshot) { + if (!firstMdc) { + buf.append(','); + } + firstMdc = False; + buf.append(k).append('=').append(v); + } + buf.append(']'); + } + + if (!event.keyValues.empty) { + buf.append(" {"); + Boolean first = True; + for ((String k, Object v) : event.keyValues) { + if (!first) { + buf.append(", "); + } + first = False; + buf.append(k).append('=').append(v.toString()); + } + buf.append('}'); } console.print(buf.toString()); diff --git a/lib_logging/src/main/x/logging/LogEvent.x b/lib_logging/src/main/x/logging/LogEvent.x index 6126343100..1e3e447e1f 100644 --- a/lib_logging/src/main/x/logging/LogEvent.x +++ b/lib_logging/src/main/x/logging/LogEvent.x @@ -14,9 +14,30 @@ const LogEvent( Level level, String message, Time timestamp, - Marker? marker = Null, + // The markers attached to this event. Empty when no markers were supplied. The + // per-level `Logger.info(..., marker=X)` API supplies at most one; the fluent + // `LoggingEventBuilder.addMarker(...)` accumulates many. Mirrors SLF4J 2.x's + // `LoggingEvent.getMarkers(): List`. + Marker[] markers = [], Exception? exception = Null, Object[] arguments = [], Map mdcSnapshot = [], String threadName = "", - ); + // Structured key/value pairs accumulated through `LoggingEventBuilder.addKeyValue`. + // Mirrors SLF4J 2.x's `KeyValuePair` list on `LoggingEvent`. Sinks that don't care + // about structured payloads can ignore this field; sinks that do (JSON layouts, + // logback's structured-arguments encoder, future `lib_logging_logback`) read it + // alongside `message`. + Map keyValues = [], + ) { + + /** + * Convenience accessor for the first marker, or `Null` when none. Sinks that only + * surface a single category line (`[marker=NAME]`) can read this without + * iterating; richer sinks should iterate [markers] directly. Mirrors SLF4J 1.x's + * `LoggingEvent.getMarker()` for one-marker callers. + */ + @RO Marker? marker.get() { + return markers.empty ? Null : markers[0]; + } +} diff --git a/lib_logging/src/main/x/logging/LogSink.x b/lib_logging/src/main/x/logging/LogSink.x index 46532671ed..219dcb7fc1 100644 --- a/lib_logging/src/main/x/logging/LogSink.x +++ b/lib_logging/src/main/x/logging/LogSink.x @@ -31,6 +31,24 @@ * substituted; the `mdcSnapshot` has already been captured. * * See `docs/CUSTOM_SINKS.md` for a worked example. + * + * # Choosing between `const` and `service` for an implementation + * + * `BasicLogger` is a `const` and references its sink through a `LogSink` field. That + * means every concrete implementation must be `Passable` — i.e. either `immutable` + * (`const`) or a `service`. Pick one of the two: + * + * - `const` — for stateless forwarders / pure adapters whose configuration is fixed + * at construction. Examples in this library: [NoopLogSink], + * [ConsoleLogSink]. Cheap to construct; methods run on the caller's fiber. + * - `service` — for sinks that carry mutable state shared across fibers: event + * buffers, hit counters, file/socket writers, async worker queues. + * Examples: [MemoryLogSink], a future `FileLogSink`, a future + * `AsyncLogSink`. + * + * The full rule, with reference examples from the platform/xunit codebases (e.g. + * `service ConsoleExecutionListener`, `service ErrorLog`), is in + * `doc/logging/DESIGN.md` under "Sink type: `const` vs `service`". */ interface LogSink { diff --git a/lib_logging/src/main/x/logging/Logger.x b/lib_logging/src/main/x/logging/Logger.x index 947e9d2e38..1b40af71d2 100644 --- a/lib_logging/src/main/x/logging/Logger.x +++ b/lib_logging/src/main/x/logging/Logger.x @@ -26,6 +26,19 @@ interface Logger { */ @RO String name; + /** + * Return a logger with the supplied `name` that shares this logger's sink. Mirrors the + * SLF4J idiom `LoggerFactory.getLogger(MyClass.class)` declared once at the top of a + * class: + * + * @Inject Logger logger; // root logger, fixed name + * static Logger PaymentLogger = logger.named("payments"); // per-class derivative + * + * Implementations are free to intern by name (and `BasicLogger` may grow that later); + * for v0 each call returns a fresh `Logger`. + */ + Logger named(String name); + @RO Boolean traceEnabled; @RO Boolean debugEnabled; @RO Boolean infoEnabled; diff --git a/lib_logging/src/main/x/logging/LoggerFactory.x b/lib_logging/src/main/x/logging/LoggerFactory.x index 9bc2bbc813..76c37b62a0 100644 --- a/lib_logging/src/main/x/logging/LoggerFactory.x +++ b/lib_logging/src/main/x/logging/LoggerFactory.x @@ -21,13 +21,17 @@ service LoggerFactory { @Inject LogSink defaultSink; - private Map cache = new HashMap(); + private @Lazy LoggerRegistry registry.calc() { + return new LoggerRegistry(defaultSink); + } /** - * Get the logger for `name`, creating it on first access. + * Get the logger for `name`, creating it on first access. Repeated calls with the + * same name return the identical `Logger`; `getLogger("a.b")` and + * `getLogger("a").named("b")` return the same instance. */ Logger getLogger(String name) { - return cache.computeIfAbsent(name, () -> new BasicLogger(name, defaultSink)); + return registry.ensure(name); } /** diff --git a/lib_logging/src/main/x/logging/LoggerRegistry.x b/lib_logging/src/main/x/logging/LoggerRegistry.x new file mode 100644 index 0000000000..ef12141fee --- /dev/null +++ b/lib_logging/src/main/x/logging/LoggerRegistry.x @@ -0,0 +1,51 @@ +/** + * Corresponds to the role played by `org.slf4j.ILoggerFactory` impls (Logback's + * `LoggerContext`, Log4j 2's `LoggerContext`): the central name-keyed cache that + * makes repeated `getLogger("a.b")` / `parent.named("b")` calls return the same + * `Logger` instance. + * + * A name-keyed intern cache for `Logger` instances. `BasicLogger.named(child)` consults + * an attached registry — when present — and returns the cached logger for the resulting + * `"."` name; without a registry, each call allocates a fresh logger. + * + * # Why interning belongs in a separate service + * + * `BasicLogger` is a `const` (so the runtime can hand it back from `@Inject Logger` + * without a service-boundary hop, see `BasicLogger.x`). A `const` cannot carry the + * mutable hash map a cache requires — the field would not be `Passable`. Splitting the + * cache out into a `service` keeps the logger immutable while still giving SLF4J's + * "same name → same Logger instance" semantics. + * + * # Scope + * + * Each registry is bound to a single [LogSink]. Loggers from different sinks live in + * different registries (or in no registry at all): `LoggerFactory` constructs one + * registry per active sink internally; user code that wants identity-stable loggers + * across an explicit non-default sink can construct its own: + * + * service ListLogSink testSink = new ListLogSink(); + * LoggerRegistry registry = new LoggerRegistry(testSink); + * Logger root = registry.ensure("root"); + * assert &root == ®istry.ensure("root"); // same instance + * assert &root.named("a.b") == ®istry.ensure("root.a.b"); + */ +service LoggerRegistry(LogSink sink) { + + private Map cache = new HashMap(); + + /** + * Return the logger for `name`, creating it on first access. Repeated calls with the + * same `name` return the identical instance. + */ + Logger ensure(String name) { + return cache.computeIfAbsent(name, () -> new BasicLogger(name, sink, this)); + } + + /** + * Discard all cached loggers. Primarily useful for tests that want a clean slate; + * production code typically never calls this. + */ + void reset() { + cache.clear(); + } +} diff --git a/lib_logging/src/main/x/logging/MDC.x b/lib_logging/src/main/x/logging/MDC.x index 581c08aca2..6c1039fac3 100644 --- a/lib_logging/src/main/x/logging/MDC.x +++ b/lib_logging/src/main/x/logging/MDC.x @@ -2,62 +2,118 @@ * Corresponds to `org.slf4j.MDC` (and the underlying `org.slf4j.spi.MDCAdapter`). * Logback's `LogbackMDCAdapter` and Log4j 2's `ThreadContext` cover the same concept. * - * Mapped Diagnostic Context — a per-fiber/thread string-keyed scratchpad that sinks can include - * in formatted output. Mirrors `org.slf4j.MDC`. + * Mapped Diagnostic Context — a per-fiber string-keyed scratchpad that sinks can include in + * formatted output. Bindings made by one fiber are visible to its callees and to fibers it + * spawns (matching SLF4J's `InheritableThreadLocal`-style propagation), but mutations from + * a child fiber do **not** bleed back to the parent: each fiber's `put` / `remove` / `clear` + * affects only its own logical-thread-of-execution. * - * The default implementation provided by this module is a no-op; values are stored but never - * read by `ConsoleLogSink`. Sinks that want MDC-aware output (a logback-style backend would) - * read `copyOfContextMap` on each event. + * Implementation: backed by a single static [ecstasy.SharedContext] holding an `immutable Map`. Every + * mutating operation copy-on-writes a new immutable map and rebinds it via + * `ecstasy.SharedContext.withValue`. Because the runtime stores `ecstasy.SharedContext` tokens on the fiber + * (not the service), and child fibers receive their own copy of the parent's token map at + * spawn time, the child's `withValue` registers on the child's chain only — the parent's + * binding stays untouched. The shared *value* (the immutable map) is safe because there is + * no mutation to leak. * - * Note: the storage is "context-local" in the Ecstasy sense — each fiber sees its own map. - * The exact mechanism is a runtime detail; see `docs/OPEN_QUESTIONS.md` for the design choice. + * `MDC` is intentionally a `const`, not a `service`. Methods on a `const` execute on the + * caller's service, which is where the fiber's token chain lives. If `MDC` were a service, + * each call would cross into a fresh service-side fiber and `withValue` would register on + * that fiber instead of the caller's — invisible to the caller after the call returns. + * + * Usage matches SLF4J's flat `put` / `remove` / `clear` API: + * + * mdc.put("requestId", id); + * try { + * logger.info("processing"); + * } finally { + * mdc.remove("requestId"); + * } + * + * The token chain grows by one entry per mutation. For typical request-scoped MDC usage + * (a handful of `put`s at the request boundary, a `clear` at the end) this is bounded by + * the request fiber's lifetime. Long-lived fibers that mutate MDC frequently will retain a + * proportional chain. */ -service MDC { +const MDC { + + /** + * The fiber-local backing store. The default value `[]` is the empty immutable map, so + * `currentMap()` never has to special-case the unbound state. + */ + /** + * The empty immutable map used as the default value of [mapContext] when no fiber + * has yet bound an MDC entry. Naming it lets the static initializer infer the type + * argument for `SharedContext`. + */ + private static immutable Map emptyMap = []; + + private static ecstasy.SharedContext> mapContext = + new ecstasy.SharedContext("mdc", emptyMap); - private Map map = new HashMap(); + /** + * Read the current fiber's view. Always returns an immutable map; the caller can hand + * the result to a sink without further copying. + */ + private immutable Map currentMap() { + return mapContext.hasValue() ?: []; + } + + /** + * Build the next immutable map by copying entries other than `dropKey` and optionally + * appending `(addKey, addValue)`. One allocation per mutation; O(n) in current size. + */ + private immutable Map derive(String? dropKey, String? addKey, String? addValue) { + HashMap next = new HashMap(); + for ((String k, String v) : currentMap()) { + if (k != dropKey) { + next.put(k, v); + } + } + if (String key ?= addKey, String value ?= addValue) { + next.put(key, value); + } + return next.makeImmutable(); + } /** * Store a value under `key`. Setting `Null` removes the key. */ void put(String key, String? value) { if (value == Null) { - map.remove(key); + remove(key); } else { - map.put(key, value); + mapContext.withValue(derive(key, key, value)); } } /** - * Read the value for `key`, or `Null` if no value is set. + * Read the value for `key`, or `Null` if no value is set on the calling fiber. */ - String? get(String key) { - return map.getOrNull(key); - } + String? get(String key) = currentMap().getOrNull(key); /** - * Remove `key` from the context. + * Remove `key` from the calling fiber's context. No-op if no value is set. */ void remove(String key) { - map.remove(key); + if (currentMap().contains(key)) { + mapContext.withValue(derive(key, Null, Null)); + } } /** - * Remove all entries. + * Remove all entries from the calling fiber's context. */ void clear() { - map.clear(); + if (!currentMap().empty) { + mapContext.withValue([]); + } } /** - * A snapshot of the current context map. Sinks call this when emitting an event, so the - * captured state is independent of any subsequent mutation by the caller. The snapshot is - * a fresh `HashMap`; callers should treat it as read-only. + * An immutable snapshot of the current fiber's context map. Sinks call this when + * emitting an event so the captured state is independent of any subsequent mutation. + * Returns the empty map if no value has been set on the calling fiber. */ - @RO Map copyOfContextMap.get() { - HashMap snapshot = new HashMap(); - for ((String k, String v) : map) { - snapshot.put(k, v); - } - return snapshot; - } + @RO Map copyOfContextMap.get() = currentMap(); } diff --git a/lib_logging/src/main/x/logging/MemoryLogSink.x b/lib_logging/src/main/x/logging/MemoryLogSink.x index 1912d4dc94..97d53973f6 100644 --- a/lib_logging/src/main/x/logging/MemoryLogSink.x +++ b/lib_logging/src/main/x/logging/MemoryLogSink.x @@ -10,8 +10,17 @@ * logger.info("processed {}", 42); * assert sink.events.size == 1; * assert sink.events[0].level == Info; + * + * # Why this sink is a `service`, not a `const` + * + * It accumulates events. The `events[]` array is mutated on every `log()` call, and + * the same instance is typically shared across many fibers (the logger under test plus + * the assertion code that reads back the events). That is structurally identical to + * `service ConsoleExecutionListener` in `lib_xunit_engine` and `service ErrorLog` in + * `platform/common` — both are stateful event collectors. See + * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`") for the full rule. */ -class MemoryLogSink +service MemoryLogSink implements LogSink { /** @@ -20,9 +29,20 @@ class MemoryLogSink public/private Level rootLevel = Trace; /** - * The captured events, in emission order. + * Mutable backing storage for captured events. Internal — must not escape the service + * boundary, because Ecstasy forbids returning a mutable array from a service call. + * External callers read [events] instead, which returns an immutable snapshot. + */ + private LogEvent[] eventList = new LogEvent[]; + + /** + * The captured events, in emission order. Each access returns a fresh immutable + * snapshot of the current state — the backing array is internal so it can stay + * mutable for `add` / `clear`, while crossing the service boundary safely. */ - public/private LogEvent[] events = new LogEvent[]; + @RO LogEvent[] events.get() { + return eventList.toArray(Constant); + } @Override Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { @@ -31,13 +51,13 @@ class MemoryLogSink @Override void log(LogEvent event) { - events.add(event); + eventList.add(event); } /** * Discard all captured events. */ void reset() { - events.clear(); + eventList.clear(); } } diff --git a/lib_logging/src/main/x/logging/MessageFormatter.x b/lib_logging/src/main/x/logging/MessageFormatter.x index eb17d0f5a7..18142b839f 100644 --- a/lib_logging/src/main/x/logging/MessageFormatter.x +++ b/lib_logging/src/main/x/logging/MessageFormatter.x @@ -7,26 +7,118 @@ * * Rules, copied from SLF4J for behavioural parity: * - Each unescaped `{}` is replaced by the next argument's `toString()`. - * - Excess arguments beyond the placeholder count are ignored. + * - Excess arguments beyond the placeholder count are ignored (subject to throwable + * promotion below). * - Excess placeholders beyond the argument count are left as-is in the output. - * - A literal `{}` in the message is escaped as `\{}`. - * - A literal backslash before an unrelated `{}` is escaped as `\\{}`. - * - The last argument, if it is an `Exception` and there is no remaining placeholder, is - * interpreted as a `cause` rather than as a substitution. This mirrors SLF4J's - * "throwable promotion" rule. - * - * The result of `format` is a `(String, Exception?)` tuple so callers can route the exception - * separately from the message text. + * - A literal `{}` in the message is escaped as `\{}` — the backslash is consumed and + * the `{}` is emitted unchanged. + * - A literal backslash before an unrelated `{}` is escaped as `\\{}` — both + * backslashes are consumed (one literal output) and the placeholder *is* substituted. + * - Throwable promotion: if the last argument is an `Exception` and no placeholder + * consumes it, it is returned as the promoted cause rather than appended to the + * message. This matches SLF4J's behaviour and is intentionally distinct from the + * explicit `cause=` parameter on the per-level methods, which always wins over a + * promoted-from-args throwable (`BasicLogger.emit` enforces that ordering). */ static service MessageFormatter { + private static Char DELIM_START = '{'; + private static Char DELIM_STOP = '}'; + private static Char ESCAPE_CHAR = '\\'; + /** * Substitute placeholders in `message` with `args`. Returns the formatted message and * the promoted-throwable (if any). */ (String formatted, Exception? cause) format(String message, Object[] args) { - // TODO(impl): port the SLF4J state machine. The skeleton here unblocks compilation - // and other modules that depend on this contract. - return (message, Null); + // Determine throwable promotion: if the trailing argument is an Exception, treat it + // as the candidate cause. The actual decision (promote vs consume as placeholder + // value) is made below based on placeholder consumption. + Int argCount = args.size; + Boolean hasTrailingExn = argCount > 0 && args[argCount - 1].is(Exception); + Int argLimit = hasTrailingExn ? argCount - 1 : argCount; + Exception? promoted = Null; + + StringBuffer buf = new StringBuffer(message.size + 32); + Int argI = 0; + Int i = 0; + Int n = message.size; + + while (i < n) { + Char c = message[i]; + + if (c != DELIM_START) { + buf.append(c); + ++i; + continue; + } + + // saw '{' — peek for '}' + if (i + 1 >= n || message[i + 1] != DELIM_STOP) { + buf.append(c); + ++i; + continue; + } + + // we have a `{}` at position i..i+1; check for escapes immediately before + Boolean escapedDelim = False; // \{} — literal {} + Boolean doubledEscape = False; // \\{} — literal \, then real placeholder + + if (i >= 1 && message[i - 1] == ESCAPE_CHAR) { + if (i >= 2 && message[i - 2] == ESCAPE_CHAR) { + doubledEscape = True; + } else { + escapedDelim = True; + } + } + + if (escapedDelim) { + // remove the escape char we already appended, then emit literal "{}" + buf.truncate(buf.size - 1); + buf.append(DELIM_START); + buf.append(DELIM_STOP); + i += 2; + continue; + } + + if (doubledEscape) { + // we already appended both backslashes; remove the trailing one (the second + // one) so the rendered text has a single literal '\' before the (real) + // substituted argument. + buf.truncate(buf.size - 1); + } + + // real placeholder — substitute or leave literal if we've run out of args + if (argI < argLimit) { + buf.append(safeToString(args[argI])); + ++argI; + } else { + buf.append(DELIM_START); + buf.append(DELIM_STOP); + } + i += 2; + } + + // Throwable promotion: the trailing exception is promoted only if no placeholder + // would have consumed it. argI counted only argLimit, so the trailing Exception is + // never substituted in here; it's always either promoted or dropped. + if (hasTrailingExn) { + promoted = args[argCount - 1].as(Exception); + } + + return (buf.toString(), promoted); + } + + /** + * Defensive `toString` that catches exceptions from the argument's own `toString()` — + * matches SLF4J's policy of never letting a malformed argument tear down the whole log + * statement. + */ + private static String safeToString(Object o) { + try { + return o.toString(); + } catch (Exception e) { + return "[FAILED toString(): " + e.text + "]"; + } } } diff --git a/lib_logging/src/main/x/logging/NoopLogSink.x b/lib_logging/src/main/x/logging/NoopLogSink.x index cac8b9fb33..b837a127a4 100644 --- a/lib_logging/src/main/x/logging/NoopLogSink.x +++ b/lib_logging/src/main/x/logging/NoopLogSink.x @@ -9,8 +9,16 @@ * * `isEnabled` always returns False so callers that respect the level check can skip * building arguments and snapshotting MDC entirely. + * + * # Why this sink is a `const` + * + * `NoopLogSink` is purely stateless — `isEnabled` always returns `False` and `log` is + * intentionally empty. There is nothing to mutate, nothing to share, nothing to fan in + * from many fibers. That is the canonical case for `const`: cheap to construct, cheap + * to pass across service boundaries, no scheduler overhead. See `doc/logging/DESIGN.md` + * ("Sink type: `const` vs `service`") for the full rule. */ -service NoopLogSink +const NoopLogSink implements LogSink { @Override diff --git a/lib_logging/src/test/x/LoggingTest/ListLogSink.x b/lib_logging/src/test/x/LoggingTest/ListLogSink.x index 257d04b911..c06ed86ac0 100644 --- a/lib_logging/src/test/x/LoggingTest/ListLogSink.x +++ b/lib_logging/src/test/x/LoggingTest/ListLogSink.x @@ -9,12 +9,28 @@ import logging.Marker; * `Logger` chose to emit, in order, without any formatting nondeterminism. * * `rootLevel` defaults to `Trace` so by default every emitted event is captured. + * + * Modelled as a `service` because it carries mutable state (`events[]`) shared across + * the fiber-under-test and the assertion code. See `doc/logging/DESIGN.md` + * ("Sink type: `const` vs `service`"). */ -class ListLogSink +service ListLogSink implements LogSink { - public/private Level rootLevel = logging.Level.Trace; - public/private LogEvent[] events = new LogEvent[]; + public/private Level rootLevel = logging.Level.Trace; + + /** + * Mutable backing list. Internal — Ecstasy forbids returning a mutable array from a + * service call, so we expose [events] as an immutable snapshot below. + */ + private LogEvent[] eventList = new LogEvent[]; + + /** + * Immutable snapshot of the captured events, in emission order. Each access copies. + */ + @RO LogEvent[] events.get() { + return eventList.toArray(Constant); + } @Override Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { @@ -23,7 +39,7 @@ class ListLogSink @Override void log(LogEvent event) { - events.add(event); + eventList.add(event); } void setLevel(Level level) { @@ -31,6 +47,6 @@ class ListLogSink } void reset() { - events.clear(); + eventList.clear(); } } diff --git a/lib_logging/src/test/x/LoggingTest/MDCTest.x b/lib_logging/src/test/x/LoggingTest/MDCTest.x new file mode 100644 index 0000000000..716ee0cc10 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/MDCTest.x @@ -0,0 +1,134 @@ +import logging.BasicLogger; +import logging.Logger; +import logging.MDC; + +/** + * Tests for [MDC]. Covers the SLF4J-style flat API (`put` / `get` / `remove` / `clear`) + * and the snapshot-on-emit path that lands MDC contents on `LogEvent.mdcSnapshot`. + * + * Per-fiber isolation across spawned services is exercised end-to-end by the manualTests + * `TestLogger.x` demo; that path needs runtime injection and a spawned service, which is + * outside the scope of these in-process unit tests. + * + * Each test calls `mdc.clear()` at the start because the test harness does not reset MDC + * state between tests and the underlying `SharedContext` is static. + */ +class MDCTest { + + @Test + void shouldStartEmpty() { + @Inject MDC mdc; + mdc.clear(); + + assert mdc.copyOfContextMap.empty; + assert mdc.get("anything") == Null; + } + + @Test + void shouldRoundTripPutAndGet() { + @Inject MDC mdc; + mdc.clear(); + + mdc.put("requestId", "r_42"); + assert mdc.get("requestId") == "r_42"; + } + + @Test + void shouldOverwriteOnRepeatedPut() { + @Inject MDC mdc; + mdc.clear(); + + mdc.put("attempt", "1"); + mdc.put("attempt", "2"); + assert mdc.get("attempt") == "2"; + assert mdc.copyOfContextMap.size == 1; + } + + @Test + void shouldRemoveKey() { + @Inject MDC mdc; + mdc.clear(); + + mdc.put("user", "u_7"); + mdc.put("session", "s_9"); + mdc.remove("user"); + + assert mdc.get("user") == Null; + assert mdc.get("session") == "s_9"; + } + + @Test + void shouldTreatPutNullAsRemove() { + @Inject MDC mdc; + mdc.clear(); + + mdc.put("key", "value"); + mdc.put("key", Null); + assert mdc.get("key") == Null; + } + + @Test + void shouldClearAllEntries() { + @Inject MDC mdc; + mdc.clear(); + + mdc.put("a", "1"); + mdc.put("b", "2"); + mdc.put("c", "3"); + mdc.clear(); + + assert mdc.copyOfContextMap.empty; + } + + @Test + void shouldAttachSnapshotToLogEvent() { + @Inject MDC mdc; + mdc.clear(); + + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("mdc.attach", sink); + + mdc.put("requestId", "r_99"); + mdc.put("user", "u_3"); + + logger.info("processing"); + + assert sink.events.size == 1; + assert sink.events[0].mdcSnapshot.size == 2; + assert sink.events[0].mdcSnapshot.getOrNull("requestId") == "r_99"; + assert sink.events[0].mdcSnapshot.getOrNull("user") == "u_3"; + } + + @Test + void shouldSnapshotIndependentlyOfSubsequentMutation() { + @Inject MDC mdc; + mdc.clear(); + + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("mdc.snap", sink); + + mdc.put("phase", "before"); + logger.info("first"); + + mdc.put("phase", "after"); + logger.info("second"); + + assert sink.events.size == 2; + assert sink.events[0].mdcSnapshot.getOrNull("phase") == "before"; + assert sink.events[1].mdcSnapshot.getOrNull("phase") == "after"; + } + + @Test + void shouldEmitEmptyMapWhenMDCIsClear() { + @Inject MDC mdc; + mdc.clear(); + + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("mdc.empty", sink); + + logger.info("no context"); + + assert sink.events.size == 1; + assert sink.events[0].mdcSnapshot.empty; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/MarkerTest.x b/lib_logging/src/test/x/LoggingTest/MarkerTest.x index 0046319eb0..552d836564 100644 --- a/lib_logging/src/test/x/LoggingTest/MarkerTest.x +++ b/lib_logging/src/test/x/LoggingTest/MarkerTest.x @@ -47,6 +47,41 @@ class MarkerTest { logger.info("authenticated", marker=audit); assert sink.events.size == 1; + assert sink.events[0].markers.size == 1; + assert sink.events[0].markers[0].name == "AUDIT"; + // Backwards-compat single-marker accessor. assert sink.events[0].marker?.name == "AUDIT" : assert; } + + @Test + void shouldPropagateMultipleMarkersThroughFluentBuilder() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("multi.marker", sink); + Marker audit = new BasicMarker("AUDIT"); + Marker security = new BasicMarker("SECURITY"); + + logger.atInfo() + .addMarker(audit) + .addMarker(security) + .log("login attempt"); + + assert sink.events.size == 1; + assert sink.events[0].markers.size == 2; + assert sink.events[0].markers[0].name == "AUDIT"; + assert sink.events[0].markers[1].name == "SECURITY"; + // The single-marker compat accessor returns the first one. + assert sink.events[0].marker?.name == "AUDIT" : assert; + } + + @Test + void shouldEmitNoMarkersWhenNoneAttached() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("no.marker", sink); + + logger.info("plain message"); + + assert sink.events.size == 1; + assert sink.events[0].markers.empty; + assert sink.events[0].marker == Null; + } } diff --git a/lib_logging/src/test/x/LoggingTest/MessageFormatterTest.x b/lib_logging/src/test/x/LoggingTest/MessageFormatterTest.x new file mode 100644 index 0000000000..3ee2e36366 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/MessageFormatterTest.x @@ -0,0 +1,91 @@ +import logging.MessageFormatter; + +/** + * Tests for [MessageFormatter] — the SLF4J-shaped `{}` substitution + throwable promotion + * helper. Cases are lifted from the SLF4J reference suite to keep behavioural parity + * obvious. + */ +class MessageFormatterTest { + + @Test + void shouldReturnPatternUnchangedWhenNoArgs() { + (String formatted, Exception? cause) = MessageFormatter.format("hello world", []); + assert formatted == "hello world"; + assert cause == Null; + } + + @Test + void shouldSubstituteSinglePlaceholder() { + (String formatted, Exception? cause) = MessageFormatter.format("hello {}", ["world"]); + assert formatted == "hello world"; + assert cause == Null; + } + + @Test + void shouldSubstituteMultiplePlaceholders() { + (String formatted, _) = MessageFormatter.format("a={} b={} c={}", ["1", "2", "3"]); + assert formatted == "a=1 b=2 c=3"; + } + + @Test + void shouldLeaveExcessPlaceholdersLiteral() { + (String formatted, _) = MessageFormatter.format("a={} b={}", ["only-one"]); + assert formatted == "a=only-one b={}"; + } + + @Test + void shouldDropExcessArguments() { + (String formatted, _) = MessageFormatter.format("a={}", ["used", "extra"]); + assert formatted == "a=used"; + } + + @Test + void shouldEscapePlaceholderWithSingleBackslash() { + // "\{}" — backslash is consumed, "{}" is emitted literally + (String formatted, _) = MessageFormatter.format("escaped: \\{}", ["X"]); + assert formatted == "escaped: {}"; + } + + @Test + void shouldKeepLiteralBackslashWithDoubleEscape() { + // "\\{}" — both backslashes consumed (one literal '\'), placeholder substitutes + (String formatted, _) = MessageFormatter.format("\\\\{}", ["X"]); + assert formatted == "\\X"; + } + + @Test + void shouldHandleAdjacentPlaceholders() { + (String formatted, _) = MessageFormatter.format("{}{}{}", ["a", "b", "c"]); + assert formatted == "abc"; + } + + @Test + void shouldPromoteTrailingException() { + Exception boom = new Exception("boom"); + (String formatted, Exception? cause) = + MessageFormatter.format("op failed: {}", ["disk", boom]); + assert formatted == "op failed: disk"; + assert cause == boom; + } + + @Test + void shouldPromoteTrailingExceptionWithoutPlaceholder() { + Exception boom = new Exception("boom"); + (String formatted, Exception? cause) = + MessageFormatter.format("no placeholder", [boom]); + assert formatted == "no placeholder"; + assert cause == boom; + } + + @Test + void shouldHandleStrayBraces() { + (String formatted, _) = MessageFormatter.format("a { b } c {} d", ["X"]); + assert formatted == "a { b } c X d"; + } + + @Test + void shouldHandleEmptyPattern() { + (String formatted, _) = MessageFormatter.format("", ["unused"]); + assert formatted == ""; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/NamedLoggerTest.x b/lib_logging/src/test/x/LoggingTest/NamedLoggerTest.x new file mode 100644 index 0000000000..ce4ff648c6 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/NamedLoggerTest.x @@ -0,0 +1,98 @@ +import logging.BasicLogger; +import logging.Logger; +import logging.LoggerRegistry; + +/** + * Tests for `Logger.named(String)` — the SLF4J `LoggerFactory.getLogger(class)` analogue. + * The derived logger must: + * - carry the supplied `name`; + * - share the parent's `sink`, so `LogEvent.loggerName` reflects the derived name on + * events the *child* emits while events emitted from the parent keep the parent's + * name; + * - obey the parent's level threshold (because the threshold lives on the sink). + */ +class NamedLoggerTest { + + @Test + void shouldDeriveLoggerWithSuppliedName() { + ListLogSink sink = new ListLogSink(); + Logger root = new BasicLogger("root", sink); + Logger child = root.named("payments"); + + assert child.name == "payments"; + } + + @Test + void shouldRouteChildEventsThroughSharedSink() { + ListLogSink sink = new ListLogSink(); + Logger root = new BasicLogger("root", sink); + Logger child = root.named("payments"); + + root.info("from root"); + child.info("from child"); + + assert sink.events.size == 2; + assert sink.events[0].loggerName == "root"; + assert sink.events[1].loggerName == "payments"; + } + + @Test + void shouldRespectParentSinkLevelThreshold() { + ListLogSink sink = new ListLogSink(); + sink.setLevel(logging.Level.Warn); + Logger root = new BasicLogger("root", sink); + Logger child = root.named("noisy"); + + // The child's level threshold is its sink's threshold (which is the parent's sink), + // so a Debug event from the child is dropped just like one from the parent. + child.debug("muted"); + child.warn ("audible"); + + assert sink.events.size == 1; + assert sink.events[0].message == "audible"; + assert sink.events[0].loggerName == "noisy"; + } + + @Test + void shouldChainNaming() { + ListLogSink sink = new ListLogSink(); + Logger root = new BasicLogger("root", sink); + Logger a = root.named("a"); + Logger ab = a.named("a.b"); + + assert a.name == "a"; + assert ab.name == "a.b"; + + ab.info("hi"); + assert sink.events.size == 1; + assert sink.events[0].loggerName == "a.b"; + } + + @Test + void shouldInternChildrenWhenRegistryAttached() { + ListLogSink sink = new ListLogSink(); + LoggerRegistry registry = new LoggerRegistry(sink); + + Logger root = registry.ensure("root"); + Logger rootAgain = registry.ensure("root"); + // Two registry lookups for the same name return the same instance. + assert &root == &rootAgain; + + // `named` consults the registry when one is attached, so identity is also + // stable when reaching the same logger by different paths. + Logger ab1 = root.named("a.b"); + Logger ab2 = registry.ensure("a.b"); + assert &ab1 == &ab2; + } + + @Test + void shouldNotInternWhenRegistryAbsent() { + ListLogSink sink = new ListLogSink(); + Logger root = new BasicLogger("root", sink); // no registry + + // Without a registry, each `named` call allocates fresh. + Logger a1 = root.named("a"); + Logger a2 = root.named("a"); + assert &a1 != &a2; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/StructuredLoggingTest.x b/lib_logging/src/test/x/LoggingTest/StructuredLoggingTest.x new file mode 100644 index 0000000000..065719c83c --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/StructuredLoggingTest.x @@ -0,0 +1,78 @@ +import logging.BasicLogger; +import logging.Logger; + +/** + * Bare-minimum SLF4J 2.x structured-logging test: verifies that + * `LoggingEventBuilder.addKeyValue(...)` actually flows through to a `LogEvent.keyValues` + * map that sinks can read. Mirrors SLF4J's KV-on-builder behaviour without taking on its + * full `KeyValuePair` data model — for v0 a `Map` is enough. + */ +class StructuredLoggingTest { + + @Test + void shouldCarrySingleKeyValueThroughBuilder() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("kv.single", sink); + + logger.atInfo() + .addKeyValue("requestId", "r_42") + .log("payment processed"); + + assert sink.events.size == 1; + assert sink.events[0].message == "payment processed"; + assert sink.events[0].keyValues.size == 1; + assert sink.events[0].keyValues.getOrNull("requestId").as(String) == "r_42"; + } + + @Test + void shouldCarryMultipleKeyValuesAndPreserveOrder() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("kv.many", sink); + + Int amount = 100; + logger.atInfo() + .addKeyValue("requestId", "r_99") + .addKeyValue("userId", "u_7") + .addKeyValue("amount", amount) + .log("checkout completed"); + + assert sink.events.size == 1; + assert sink.events[0].keyValues.size == 3; + + // ListMap preserves insertion order; this matches SLF4J's KeyValuePair list ordering. + String[] orderedKeys = sink.events[0].keyValues.keys.toArray(); + assert orderedKeys[0] == "requestId"; + assert orderedKeys[1] == "userId"; + assert orderedKeys[2] == "amount"; + + assert sink.events[0].keyValues.getOrNull("amount").as(Int) == 100; + } + + @Test + void shouldEmitEmptyKeyValuesWhenBuilderHasNone() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("kv.empty", sink); + + logger.atInfo().log("no extras"); + + assert sink.events.size == 1; + assert sink.events[0].keyValues.empty; + } + + @Test + void shouldOverwriteValueOnDuplicateKey() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("kv.dup", sink); + + Int first = 1; + Int second = 2; + logger.atInfo() + .addKeyValue("attempt", first) + .addKeyValue("attempt", second) + .log("retry"); + + assert sink.events.size == 1; + assert sink.events[0].keyValues.size == 1; + assert sink.events[0].keyValues.getOrNull("attempt").as(Int) == 2; + } +} diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x index 9e49af40c3..b0d3eea043 100644 --- a/manualTests/src/main/x/TestLogger.x +++ b/manualTests/src/main/x/TestLogger.x @@ -1,33 +1,44 @@ /** * Manual test that exercises lib_logging end-to-end. * - * The unit tests under `lib_logging/src/test/x/LoggingTest/` pin down the API surface - * by constructing `BasicLogger` directly against an in-memory sink. That works fine for + * The unit tests under `lib_logging/src/test/x/LoggingTest/` pin down the API surface by + * constructing `BasicLogger` directly against an in-memory sink. That works fine for * verifying the API, but it does not exercise the **runtime injection** path - * (`@Inject Logger logger;`) — and there are limits to what xunit can express about - * injectability. This module is the place to drive that path explicitly. + * (`@Inject Logger logger;`) or the per-name derivation pattern + * (`logger.named("MyService")`) — both of which depend on runtime wiring. This module + * drives those paths explicitly. * - * Today, until `RTLogger.java` lands in `javatools_jitbridge` and registers a - * `Logger` factory in `nMainInjector`, `@Inject Logger logger` won't actually resolve. - * The fallback in the meantime — and the reference shape user code will write — is to - * construct a `BasicLogger` directly, which is what the `runDirect` method does. Once - * the runtime side lands, `runInjected` will start working with no source change here. + * The three sections of `run()` line up one-for-one with the three patterns user code + * is allowed to use: + * - `runDirect` — explicit `new BasicLogger(...)`. Works without the runtime. + * - `runInjected` — `@Inject Logger logger;`. Resolves to the default logger. + * - `runInjectedByName` — `@Inject Logger logger; Logger named = logger.named(...)`. + * Per-name derivation, the SLF4J `getLogger(class)` analogue. */ module TestLogger { package log import logging.xtclang.org; + import log.BasicLogger; + import log.BasicMarker; + import log.ConsoleLogSink; + import log.Logger; + import log.LogSink; + import log.Marker; + import log.MDC; @Inject Console console; void run() { - console.print("--- runDirect ---"); + console.print("--- runDirect (no injection) ---"); runDirect(); - console.print("--- runInjected (will be a no-op until runtime-side is wired) ---"); - try { - runInjected(); - } catch (Exception e) { - console.print($"runInjected not yet supported: {e.text}"); - } + console.print("--- runInjected (default logger via @Inject) ---"); + runInjected(); + + console.print("--- runInjectedByName (per-name via Logger.named) ---"); + runInjectedByName(); + + console.print("--- runMDC (per-fiber context) ---"); + runMDC(); } /** @@ -35,8 +46,8 @@ module TestLogger { * explicitly. Works today. */ void runDirect() { - log.LogSink sink = new log.ConsoleLogSink(); - log.Logger logger = new log.BasicLogger("TestLogger.direct", sink); + LogSink sink = new ConsoleLogSink(); + Logger logger = new BasicLogger("TestLogger.direct", sink); logger.info ("hello, {}", ["world"]); logger.warn ("disk space low: {} MB", [42]); @@ -52,7 +63,7 @@ module TestLogger { } // Marker. - log.Marker audit = new log.BasicMarker("AUDIT"); + Marker audit = new BasicMarker("AUDIT"); logger.info("user signed in", marker=audit); // Fluent builder. @@ -63,15 +74,59 @@ module TestLogger { } /** - * Drives the library through `@Inject Logger logger;`. This is what user code is - * meant to write; it depends on runtime work that hasn't happened yet. + * Default-logger injection. The runtime registers exactly one supplier under the + * resource name `"logger"` (see `NativeContainer.initResources`), so this is the only + * spelling that resolves: the field name `logger` matches the registered resource + * name. Any other field name — `@Inject Logger payments;` — would fail with an + * unresolved-injection error, by design. */ void runInjected() { - @Inject log.Logger logger; + @Inject Logger logger; logger.info("hello from the injected logger"); } + /** + * Per-name logger via `Logger.named(String)`. Mirrors SLF4J's + * `LoggerFactory.getLogger(MyClass.class)` idiom: inject the default logger once, + * derive named children at the call site (or store them as fields on the enclosing + * class). The derived logger shares this logger's sink, so all configuration applied + * to the default logger flows through to its descendants. + * + * Bare-essentials demo target (`doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md`): the + * message must print as `hello world` (formatted by `MessageFormatter`), not + * `hello {}` (raw). The logger-name column on the resulting line should read `Demo`. + */ + void runInjectedByName() { + @Inject Logger logger; + Logger demo = logger.named("Demo"); + demo.info("hello {}", ["world"]); + } + void failingOperation() { throw new Exception("boom"); } + + /** + * MDC end-to-end via runtime injection. Demonstrates that: + * - `@Inject MDC` resolves through the native supplier; + * - `mdc.put` bindings appear on subsequent log lines via `[mdc=…]`; + * - `mdc.remove` and `mdc.clear` take effect from the next emission onward. + */ + void runMDC() { + @Inject Logger logger; + @Inject MDC mdc; + + mdc.put("requestId", "r_42"); + mdc.put("user", "u_7"); + console.print($" [diagnostic] mdc.copyOfContextMap = {mdc.copyOfContextMap}"); + + // (A) Through the runtime-injected RTLogger (service wrapper). + logger.info("via @Inject Logger (RTLogger service)"); + + // (B) Through a directly-constructed BasicLogger (no service wrapper). + Logger direct = new BasicLogger("MDC.direct", new ConsoleLogSink()); + direct.info("via direct BasicLogger"); + + mdc.clear(); + } } From 7a9904f68c2ce6d4e243ae87b3ff67eb7f0ca3ab Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Fri, 1 May 2026 22:08:46 +0200 Subject: [PATCH 05/28] Add lib_slogging parallel module + design comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second logging library shaped after Go's log/slog so reviewers can compare it side-by-side against the SLF4J-shaped lib_logging. Both libraries target the same end-to-end functionality with different API shapes; the comparison doc lists the eleven explicit questions we want feedback on and a tentative recommendation. Carrying both is a one-time exercise — the intent is to pick one and move on after reviewer input. - lib_slogging module: Logger (concrete const), Handler (SPI), Record, Attr, open-Int Level, with TextHandler / JSONHandler / NopHandler (const) and MemoryHandler (service). Same const-vs-handler rule as lib_logging sinks. - Tests: 22 passing across LevelTest, LevelCheckTest, EmissionTest, WithTest, WithGroupTest, AttrTest, MemoryHandlerTest plus a ListHandler helper that mirrors ListLogSink. - Wired into xdk/settings.gradle.kts as a sibling XDK module. Documentation: - LIB_LOGGING_VS_LIB_SLOGGING.md: per-axis comparison (levels, context propagation, structured data, message templating, sink shape, naming, source location, async, familiarity), Ecstasy-fit notes, deep dive on log markers (what they are, who uses them, real cloud-side rendering, five side-by-side patterns, summary table), and 11 explicit reviewer questions with a tentative recommendation that leans slog with borrowed MDC. - CLOUD_INTEGRATION.md: pedagogical doc framing cloud-native deployment for readers whose deployment intuition is "ship a CLI installer." Walks through containers, orchestrators, structured-JSON-on-stdout, and the GCP / AWS / Azure adapter graphs that already exist for SLF4J and slog. Argues why API familiarity is the actual selling point — the wrong API choice silently rules Ecstasy out of common deployment patterns even when the code is fine. - OPEN_QUESTIONS.md: adds Q-D1..Q-D7 designer questions (sink type convention, @Inject inside const, const-to-service boundary diagnostics, SharedContext usage for MDC, removed-wrapper pattern, API shape preference, cross-module default-arg synthesis). Reorganises remaining work into three implementation tiers and tracks a parity table for lib_slogging. - README.md: now leads with both libraries and indexes the new comparison + cloud docs. --- doc/logging/CLOUD_INTEGRATION.md | 235 ++++++ doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md | 747 ++++++++++++++++++ doc/logging/OPEN_QUESTIONS.md | 244 ++++-- doc/logging/README.md | 36 +- lib_slogging/build.gradle.kts | 10 + lib_slogging/src/main/x/slogging.x | 60 ++ lib_slogging/src/main/x/slogging/Attr.x | 48 ++ lib_slogging/src/main/x/slogging/Handler.x | 66 ++ .../src/main/x/slogging/JSONHandler.x | 65 ++ lib_slogging/src/main/x/slogging/Level.x | 49 ++ lib_slogging/src/main/x/slogging/Logger.x | 125 +++ .../src/main/x/slogging/MemoryHandler.x | 64 ++ lib_slogging/src/main/x/slogging/NopHandler.x | 21 + lib_slogging/src/main/x/slogging/Record.x | 26 + .../src/main/x/slogging/TextHandler.x | 111 +++ lib_slogging/src/test/x/SLoggingTest.x | 20 + .../src/test/x/SLoggingTest/AttrTest.x | 42 + .../src/test/x/SLoggingTest/EmissionTest.x | 65 ++ .../src/test/x/SLoggingTest/LevelCheckTest.x | 38 + .../src/test/x/SLoggingTest/LevelTest.x | 45 ++ .../src/test/x/SLoggingTest/ListHandler.x | 59 ++ .../test/x/SLoggingTest/MemoryHandlerTest.x | 38 + .../src/test/x/SLoggingTest/WithGroupTest.x | 42 + .../src/test/x/SLoggingTest/WithTest.x | 78 ++ xdk/settings.gradle.kts | 1 + 25 files changed, 2282 insertions(+), 53 deletions(-) create mode 100644 doc/logging/CLOUD_INTEGRATION.md create mode 100644 doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md create mode 100644 lib_slogging/build.gradle.kts create mode 100644 lib_slogging/src/main/x/slogging.x create mode 100644 lib_slogging/src/main/x/slogging/Attr.x create mode 100644 lib_slogging/src/main/x/slogging/Handler.x create mode 100644 lib_slogging/src/main/x/slogging/JSONHandler.x create mode 100644 lib_slogging/src/main/x/slogging/Level.x create mode 100644 lib_slogging/src/main/x/slogging/Logger.x create mode 100644 lib_slogging/src/main/x/slogging/MemoryHandler.x create mode 100644 lib_slogging/src/main/x/slogging/NopHandler.x create mode 100644 lib_slogging/src/main/x/slogging/Record.x create mode 100644 lib_slogging/src/main/x/slogging/TextHandler.x create mode 100644 lib_slogging/src/test/x/SLoggingTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/AttrTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/EmissionTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/LevelTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/ListHandler.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/WithTest.x diff --git a/doc/logging/CLOUD_INTEGRATION.md b/doc/logging/CLOUD_INTEGRATION.md new file mode 100644 index 0000000000..3f3fd3ff0a --- /dev/null +++ b/doc/logging/CLOUD_INTEGRATION.md @@ -0,0 +1,235 @@ +# Cloud-native logging — why API familiarity matters + +This doc explains *why* the choice between SLF4J-shaped and slog-shaped logging +APIs (`lib_logging` vs `lib_slogging`, see `LIB_LOGGING_VS_LIB_SLOGGING.md`) is +not just an aesthetic call. It is the entry point to the major cloud +observability ecosystems — and the wrong API choice silently rules Ecstasy out +of common deployment patterns even when the code itself is fine. + +The doc is written assuming the reader is an expert systems engineer — runtimes, +compilers, JIT — but has not personally deployed a service to GCP / AWS / Azure +in production. That is a common, perfectly reasonable shape; the cloud +ecosystems are sprawling and most of the day-to-day knowledge lives in +operations teams, not language teams. So we start by laying out what +"deploying an application" looks like in 2026, then explain how logging fits +into that, then come back to "and that is why this API choice matters." + +## A brief geography of how applications are deployed today + +For a long time — through about 2010 in mainstream practice — the unit of +software delivery was an **installer that produced a process on a machine**: an +MSI on Windows, a `.deb` on Linux, an installed shell command. Users started +the process, the process wrote log lines to a file under `/var/log/` or +`%PROGRAMDATA%\\logs`, and ops people logged into the machine and tailed +the file when something went wrong. The logging API's job was to format text +into that file. + +That world still exists for desktop applications and CLI tools (the XDK itself +is delivered this way, and rightly so). But for **server-side applications and +services** — anything that handles requests, reads from a database, calls a +peer service — the deployment model has been replaced by a fundamentally +different shape: + +1. The application is built into a **container image** (most often via Docker / + OCI). The image is a self-contained filesystem with the runtime, the app, + and its dependencies — conceptually "an executable, but for a Linux + sandbox." +2. The image is run not on one machine but on a **cluster orchestrator**: GKE + (GCP), EKS (AWS), AKS (Azure), or a managed equivalent (Cloud Run, App + Runner, Container Apps). The orchestrator decides which physical node runs + the container, can run dozens or thousands of replicas, and replaces them + automatically when nodes fail. +3. There is **no persistent local filesystem** the application can rely on for + logs. Containers come and go; their disks are wiped between restarts. A + log file inside a container is gone the moment that container dies — which + for a healthy fleet might be every 12 hours. +4. So, by convention, **the application writes structured log records to its + own stdout/stderr** as one record per line. The orchestrator captures + those streams and forwards them, fully automatically, to a managed + logging product (Cloud Logging, CloudWatch Logs, Azure Monitor) — which + indexes them, makes them queryable through a web UI, sends alerts when + patterns match, and retains them per a configured policy. + +Two consequences flow from (4) and shape every modern logging API: + +- **Logs are queried, not tailed.** Operations engineers don't `ssh` into the + machine and `tail -f`; they open a web console, type a structured query + ("show me all `ERROR`-level events in the last hour with `requestId=r_42`"), + and the cloud product returns results across thousands of containers as one + search. +- **Each log line is consumed as a record, not as text.** The cloud product + parses the line as a JSON object (or an equivalent structured format) and + indexes every field. A string `"INFO user u_3 logged in"` is a single + searchable string. A JSON object `{"level":"INFO","user":"u_3","action":"login"}` + has every field independently queryable, filterable, groupable, and + alert-able. + +The shift from "logs are text in a file" to "logs are queryable JSON streams" +is the single largest operational change in server software since the year +2000. Every popular logging API since 2014 (slf4j-2.x, log4j2, slog, +zap, tracing, Serilog) was retrofitted or freshly designed around it. + +## What "the cloud product on the other side" actually does + +Pick GCP Cloud Logging as the canonical example. When a container's stdout +emits a JSON line, the GKE node's logging agent (`fluent-bit` by default) +ships it to the Cloud Logging API. The product then: + +1. **Parses** the JSON. Each top-level key becomes an indexed field. +2. **Maps** specific keys to its own first-class concepts: + - `severity` (or sometimes `level`) — must be one of + `DEBUG / INFO / WARNING / ERROR / CRITICAL`. Drives the colour of the row + and feeds alert policies. + - `message` (or `msg`) — the human-readable text shown in the UI's main + column. + - `trace` and `spanId` — if present, link the log entry to a distributed + trace (Cloud Trace, Jaeger). Click the row, see the entire request flow. + - `labels.*` — a string-only key/value map exposed as filterable tags in + the UI (the same role as Logback's `Marker` plays in older configs). + - `jsonPayload.*` — anything else, fully indexed and queryable. +3. **Persists** the record according to retention policy (30 days default + for the basic tier, up to 10 years on the audit tier). +4. **Indexes** the fields for search, alerting, and metric extraction. + +CloudWatch Logs (AWS) and Azure Monitor (Application Insights) work the same +way — different product names, near-identical conventions. JSON in, structured +search out. + +The key takeaway: the cloud-side product **reads the structured fields the +logging API put on the wire**. If the API never emits a `requestId` field, +you can't filter on it. If the API emits everything as one big string, all +querying degrades to substring search. The expressiveness of the logging API +sets a hard ceiling on the operability of the deployed system. + +## The standard adapters — where API familiarity lives + +The logging APIs we are comparing exist not in isolation but in a +*pre-existing graph of adapters* that translate them into the schemas the +cloud products expect. + +### Google Cloud Logging + +- **SLF4J / Logback**: Google ships [`com.google.cloud.logging.logback.LoggingAppender`](https://cloud.google.com/logging/docs/setup/java). + Drop it into your `logback.xml`, and every Logback `LoggingEvent` becomes a + Cloud Logging entry with `severity` mapped from level, `message` mapped from + the formatted message, MDC keys mapped to `jsonPayload`, **markers mapped to + `labels`**, and trace IDs mapped from the SLF4J `MDC` keys + `traceId`/`spanId`/`traceSampled`. Existing Java code that already used SLF4J + needs *no source changes* to send logs to Cloud Logging — it is a config + file change. +- **Go `log/slog`**: [`cloud.google.com/go/logging.HandlerOptions`](https://pkg.go.dev/cloud.google.com/go/logging#HandlerOptions) + produces a `slog.Handler` that writes records directly to the Cloud Logging + API. slog `Attr`s become `jsonPayload` fields; the slog level numbers map + cleanly to GCP's severity ladder. + +### AWS CloudWatch Logs + +- **SLF4J / Logback**: the community-maintained [`cloudwatch-logback-appender`](https://github.com/j256/cloudwatchlogbackappender) + or the more common pattern of `logstash-logback-encoder` writing JSON to + stdout, which the Lambda runtime / ECS Firelens picks up automatically. MDC + keys become top-level JSON fields; markers go into a `markers` array. AWS + X-Ray trace IDs propagate through the same MDC channel. +- **Go `log/slog`**: stdout JSON is the default; AWS Lambda's runtime captures + it directly. Alternatively, the [`slog-cloudwatch`](https://pkg.go.dev/github.com/jutkko/slog-cloudwatch) + community handler writes to CloudWatch Logs directly. + +### Azure Monitor / Application Insights + +- **SLF4J / Logback**: [`applicationinsights-logging-logback`](https://learn.microsoft.com/en-us/azure/azure-monitor/app/java-standalone-arguments) + ships every `LoggingEvent` as a `TraceTelemetry`. MDC becomes "custom + properties"; **Logback markers become custom properties** keyed + `markerName -> "true"`. App Insights' Kusto query language indexes them all. +- **Go `log/slog`**: any slog handler that writes JSON to stdout works + through Azure Container Apps' built-in log capture; community packages also + exist. + +The pattern is identical across the three providers: the API's concepts +(level, MDC keys, markers, structured fields) are pre-mapped to the cloud +product's concepts (severity, custom properties, labels, indexed JSON +payload) **by adapters that already exist and are battle-tested**. + +## Why this matters for the `lib_logging` vs `lib_slogging` decision + +If Ecstasy chooses an API shape that is *isomorphic* to one of these +two ecosystems, the path to "drop in an Ecstasy server, view its logs in +Cloud Logging / CloudWatch / App Insights" is "write a thin adapter that +translates `LogEvent` / `Record` to the provider's wire format" — typically a +weekend project, often community-maintained. + +If Ecstasy chooses a *novel* shape — well-designed, perhaps cleaner than +either — every prospective adopter has to write a custom adapter from scratch +for every provider they care about, no community will share that work, and +every team's adapter is a private liability they have to maintain. The +"cleaner" API ends up gated on the willingness to maintain a parallel +ecosystem of adapters. + +Concrete read on the two libraries' positions: + +- **`lib_logging`** (SLF4J shape) gets the largest ready-made adapter graph. + Every JVM team's mental model maps onto it: `Logger`, `MDC`, `Marker`, + `Level`, fluent builder. The cloud-side schema mappings (level → severity, + MDC → jsonPayload, marker → labels) carry over essentially untouched. A + prospective Ecstasy user who has been writing SLF4J in Java for fifteen + years can deploy an Ecstasy service to GCP and have its logs render + correctly in the Cloud Logging UI with `requestId` filterable, without + having to learn a new mental model. + +- **`lib_slogging`** (Go slog shape) gets the second-largest adapter graph. + Cloud Logging, CloudWatch, and App Insights all have native slog handlers. + The audience is narrower — cloud-native Go engineers — but it is the + fastest-growing audience in production observability, and the slog + attribute model maps to JSON-payload-shaped logging *more* directly than + SLF4J does (no markers / MDC reconciliation step; everything is already an + attribute). + +A library that picks neither shape — say, a clean-slate Ecstasy-native API +based on tagged unions and `SharedContext` — *can* be built, and may even be +simpler to use in pure Ecstasy, but it ships with no adapter graph at all. +The first user who tries to deploy to Cloud Logging finds out the hard way +that they're writing the integration themselves. + +## What "do the right thing for cloud" means in API terms + +Independent of the SLF4J-vs-slog decision, here are the API features that +every modern cloud product relies on, ordered by how badly things break if +the API doesn't expose them: + +1. **Per-event structured fields.** Every emission must be able to carry + key/value pairs that survive verbatim into the wire format. SLF4J 2.x's + `KeyValuePair` list and slog's `Attr` both satisfy this. **Both + `lib_logging` and `lib_slogging` already do.** +2. **A field for the trace/span ID.** Distributed tracing is now table + stakes; the logging product correlates trace IDs to render the request + flow. SLF4J does this through MDC keys (`traceId`, `spanId`) by + convention; slog through dedicated attrs. **Both libraries can carry + these; the integration code chooses which keys to use.** +3. **A canonical level ladder that maps to provider severity.** GCP wants + `DEBUG / INFO / WARNING / ERROR / CRITICAL`. SLF4J's five levels map + directly. slog's open `Int` levels map via comparison. **Both fine.** +4. **A way to attach context without threading parameters.** SLF4J's MDC, + slog's `Logger.With(...)` chains. Without this, request-scoped fields + (like `requestId`) need to be passed through every function, which is + a non-starter for real applications. **Both libraries provide this in + different ways — see the comparison doc § 3.2.** +5. **An async / batched sink.** When a service emits 10k log events per + second, blocking the request thread on disk I/O is unacceptable. Both + libraries' `AsyncLogSink` / `AsyncHandler` wrappers (W-5 in + `OPEN_QUESTIONS.md`) cover this; we have not implemented either yet. + +Note that *all five* of these features exist for the cloud-deployment story, +not for the local-tail-the-log-file story. They are exactly the features that +were retrofitted onto SLF4J between 2018 and 2022, and the features that +slog was designed to have from day one. + +## TL;DR + +Choosing between `lib_logging` (SLF4J) and `lib_slogging` (slog) is **not** a +question about which API reads better in isolation. It is a question about +which existing cloud-side adapter ecosystem an Ecstasy deployment plugs into +on day one. SLF4J's adapter graph is larger and more mature; slog's is +younger, growing fastest, and has a slightly cleaner mapping into structured +JSON output. Either choice is defensible; a third "cleaner" choice means +shipping into an empty ecosystem and writing the integrations yourself. + +This document exists so that decision is made with that context, not without +it. diff --git a/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md b/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md new file mode 100644 index 0000000000..e9d0676c2b --- /dev/null +++ b/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md @@ -0,0 +1,747 @@ +# `lib_logging` vs `lib_slogging` — design comparison + +Two parallel XDK logging libraries, both modelling the same end-to-end functionality +but each shaped after a different prior-art tradition: + +- **`lib_logging`** — modelled on SLF4J 2.x + Logback. Familiar to anyone with + Java/Kotlin/Scala experience. Mature ecosystem semantics: `Logger.info("hello {}", + arg)`, `MDC.put`, `MarkerFactory.getMarker`, fluent `.atInfo()` builder, sink/appender + split, severity-named levels. +- **`lib_slogging`** — modelled on Go 1.21's `log/slog`. The most modern "well-known" + structured logger. Attribute-first API: `logger.Info("hello", "key", value)` or + `logger.LogAttrs(...)`, derived loggers via `With(...)`, no MDC, no markers, + extensible integer levels, group nesting. + +Both libraries target the same set of features end-to-end so a reviewer can pick +either, drop it into an Ecstasy program, and get the equivalent operational +capability. The point of carrying two implementations is not to ship both forever — +it's to let the XTC language designers and prospective users see the two designs +side-by-side, in real Ecstasy, and tell us which one fits the language better. + +This document: + +1. shows the same example written in both APIs; +2. compares the two designs across nine concrete axes; +3. analyses fit with Ecstasy idioms (`const` / `service` / `SharedContext` / fluent + builders); +4. lists the reviewer questions that motivate the experiment; +5. records a tentative recommendation, knowing it may shift after review. + +> Status note: `lib_slogging` is currently a skeleton (interfaces + a TextHandler stub). +> The SLF4J-shaped library is roughly feature-complete (47 unit tests passing). See +> `OPEN_QUESTIONS.md` for the explicit tracking list of items still missing on each +> side; we do not ask reviewers to compare designs until both libraries reach the same +> waterline. + +--- + +## 1. Same scenario in both APIs + +A nontrivial example: an HTTP request handler logs request entry, attaches a +correlation id and user id to a context that propagates into a child fiber, and +records a structured event with an exception cause. + +### SLF4J-shaped (`lib_logging`) + +```ecstasy +import logging.Logger; +import logging.MDC; +import logging.MarkerFactory; + +@Inject Logger logger; +@Inject MDC mdc; + +void handle(Request req) { + mdc.put("requestId", req.id); + mdc.put("user", req.userId); + try { + logger.info("processing {} {}", [req.method, req.path]); + process(req); + } catch (Exception e) { + logger.atError() + .setMessage("request failed") + .addMarker(MarkerFactory.getMarker("AUDIT")) + .addKeyValue("status", 500) + .setCause(e) + .log(); + } finally { + mdc.clear(); + } +} +``` + +### slog-shaped (`lib_slogging`) + +```ecstasy +import slogging.Logger; +import slogging.Attr; + +@Inject Logger logger; + +void handle(Request req) { + Logger reqLog = logger.with([ + Attr.of("requestId", req.id), + Attr.of("user", req.userId), + ]); + try { + reqLog.info("processing", [ + Attr.of("method", req.method), + Attr.of("path", req.path), + ]); + process(req); + } catch (Exception e) { + reqLog.error("request failed", [ + Attr.of("audit", True), + Attr.of("status", 500), + Attr.of("err", e), + ]); + } +} +``` + +Both produce equivalent structured output. The shapes diverge in how +*correlation* (per-request id) and *categorisation* (audit channel) are expressed. + +--- + +## 2. Per-axis comparison + +| Axis | `lib_logging` (SLF4J 2.x) | `lib_slogging` (Go slog) | +|---|---|---| +| **Levels** | Five named: `Trace`, `Debug`, `Info`, `Warn`, `Error` (+ `Off`). Closed enum. | Integer `Level(severity)` with named constants `Debug=-4`, `Info=0`, `Warn=4`, `Error=8`. Open: callers can define `Level(2, "NOTICE")`. | +| **Context propagation** | Per-fiber `MDC` (`SharedContext`-backed map). Implicit — every emission snapshots it. | Explicit derived loggers via `Logger.with(attrs)`; no thread-local. Optionally a fiber-scoped logger via `SharedContext`. | +| **Structured data** | `Marker` for category, `addKeyValue("k", v)` for KV pairs, `arguments` for `{}` substitution slots. Three concepts. | One concept: `Attr(key, value)`. Attributes are the first-class carrier. | +| **Message** | Templated: `info("user {} did {}", [name, action])`. SLF4J-style `{}` placeholders. | Free-form string + attrs separately. No interpolation. | +| **Sinks / handlers** | `LogSink.isEnabled(name, level, marker)`, `LogSink.log(event)`. Two methods. | `Handler.enabled(level)`, `Handler.handle(record)`, `Handler.withAttrs(attrs)`, `Handler.withGroup(name)`. Four methods — handler can pre-resolve attrs at derivation time. | +| **Logger naming** | Hierarchical names: `logger.named("payments")`, `payments.named("stripe")` → `"payments.stripe"`. SLF4J idiom. | Loggers are uniform; categorisation lives in attrs (`"component" -> "payments.stripe"`). | +| **Source location** | Not captured by default. | `Record.pc`/`Record.source` capture is opt-in but a first-class concern. | +| **Async / batching** | Wrapper sink (`AsyncLogSink`, future) drains a bounded queue. | Wrapper handler — same shape. | +| **Familiarity bench** | Java/Kotlin/Scala. Anyone who has done structured logging on the JVM. | Go (1.21+). Increasingly the modern go-to in cloud-native code. | + +--- + +## 3. Per-axis analysis + +### 3.1 Levels — closed enum vs open integer + +SLF4J's five levels are an industry consensus, easy to pattern-match on, and remove a +class of "what's the right level for this?" questions because the choice is small. +The cost: introducing `Notice`, `Critical`, or `Audit` requires either reinterpreting +an existing level or adding a new one to the enum (which is a breaking change). + +slog's integer levels accept extension without library changes. Custom levels +compose with comparison; tooling that filters on `>= Warn` keeps working when a +user adds `Notice = 2`. The cost: there is no canonical `Notice`; two libraries can +both define `Notice` at different integer values and silently disagree. + +**Ecstasy fit.** Both designs are expressible. The slog model is closer to how +Ecstasy already treats severity in many places (raw `Int`-shaped severity in +`Console`, `xunit_engine` levels), and integrates naturally with the +`Comparable`/`Orderable` machinery. The SLF4J model is a `const` enum with explicit +methods — also clean, but rigidly closed. + +### 3.2 Context propagation — `MDC` vs `With(attrs)` + +This is the single biggest design fork. + +**`MDC`.** Implicit. Every log call captures the current `MDC` snapshot regardless of +whether the calling code knows about it. This is enormously convenient for +cross-cutting concerns (request ids, tracing) — the framework injects ids once and +every downstream log line gets them for free. The cost is hidden state: code looks +pure but isn't, you can't tell from reading a log call whether it will pick up an MDC +key. + +In `lib_logging` the implementation is `SharedContext`-backed for per-fiber scope, so +unlike Java MDC it doesn't leak into sibling fibers. That removes the worst class of +real-world MDC bugs. But the *implicitness* remains. + +**`With(attrs)`.** Explicit. Code reads "this logger carries these attributes." When a +function wants to log with a request id, it must accept the request-aware logger — +either as a parameter or as a field set up at construction. There is no global state. +The cost is verbosity: every layer that wants the request id either accepts a +`Logger` parameter or accepts a context object that can produce one. + +In Go, slog reaches for `context.Context` to pass loggers through the call graph +without adding parameters everywhere. The Ecstasy equivalent is `SharedContext` +or service-local `Logger` fields — feasible but not idiomatic yet. + +**Ecstasy fit.** The MDC story is *already implemented and works*: `const MDC` over an +immutable-map `SharedContext` is small (~50 lines) and per-fiber. The slog story is +*mechanically simple* (just derive a new const Logger with extra attrs) but loses the +"library code that doesn't know about the request id still gets the request id" trick +unless the Logger parameter threads everywhere. We want reviewer thoughts on which +side of this tradeoff Ecstasy programs should be on. + +### 3.3 Structured data — three concepts vs one + +#### Aside: what is a log marker? + +A **log marker** is a *named tag* attached to a log record that gives sinks a +first-class "category" to filter on, separate from the message text and the +severity level. SLF4J introduced the concept in 2005; the same idea exists in +Log4j 2 (`Marker`) and is conceptually similar to `MDC` keys in Logback config or +"properties" in log4net — but markers travel *with the event* whereas MDC travels +*with the thread*. + +Concrete use cases — all from production deployments using SLF4J/Logback: + +- **`AUDIT`**: route every event tagged with this marker to an *append-only* audit + log that satisfies SOX / PCI-DSS / HIPAA retention rules. The same logger writes + ordinary events to a normal file; the audit log only sees marker-tagged events. +- **`SECURITY`** / **`SECURITY.BREACH`**: forward security-relevant events to a + SIEM (Splunk, Elastic, Datadog) on a separate channel, optionally with stricter + encryption / redaction filters in front. +- **`SLOW_QUERY`** / **`PERFORMANCE`**: emit perf-suspect events to an APM + pipeline (New Relic, AppDynamics) without the noise of regular debug logs. +- **`BILLING`** / **`CONFIDENTIAL`**: route financial-impact events to a system + with longer retention and tighter access controls. +- **`PUBLIC_API_REQUEST`**: tee API-boundary events to a separate access log used + for traffic analysis and rate-limit tuning. + +Markers also support a *parent/child DAG*: in SLF4J, `SECURITY.contains(BREACH)` +returns true if `BREACH` was added as a child of `SECURITY`. A filter configured +on `SECURITY` therefore catches any descendant marker without needing to know +their names. This is the marker model's only real edge over a plain +"`is_security: true`" attribute — and almost no production code uses it. + +#### Who actually uses markers? + +- **Logback** (the dominant SLF4J binding): markers are first-class. `TurboFilter` + and `EvaluatorFilter` can route events by marker. Most JSON encoders emit the + marker name as a structured field. Used in **almost every Java enterprise app** + built between roughly 2010 and 2020. +- **Log4j 2**: `MarkerManager` interns markers; `MarkerFilter` accepts/denies by + marker name. Used heavily in Apache projects (Kafka, Cassandra, Solr) and in + legacy enterprise codebases. +- **SLF4J 2.x** itself: `Logger.atInfo().addMarker(...)` is the canonical fluent + builder usage, and `LoggingEvent.getMarkers(): List` (the multi-marker + shape we just adopted) is the v2 wire format. +- **Apache Camel, Spring Boot, JBoss / WildFly, ActiveMQ, Hazelcast**: all surface + marker-based filtering in their default Logback / Log4j configurations. + +Outside the JVM: + +- **Go's `log/slog`** *deliberately rejects* markers. Categorisation is just + `slog.Bool("audit", true)` or `slog.String("category", "security")`. +- **Python's `logging`**: no markers. Categories live in the logger name + hierarchy (`security.breach`) and in `extra={...}` dict attrs. +- **.NET's `Microsoft.Extensions.Logging`**: no markers. Categories are logger + names; structured fields go in the `state` object. +- **Rust's `tracing`**: no markers. `target` field on each event approximates the + same idea; otherwise use `field::display(...)` attributes. + +The pattern: **markers are a JVM-ecosystem feature**, born of SLF4J, used widely +*inside* the JVM, and deliberately excluded from every well-known modern logger +designed since 2015. + +#### What is the actual payoff? + +Three things, in decreasing order of how often they justify the API surface: + +1. **Backend routing.** A sink looks at the marker and decides where the event + goes (audit log vs. main log vs. SIEM). This is how 90% of production marker + usage works in practice. It is the *only* real pull for keeping markers. +2. **Marker DAG queries.** `SECURITY.contains(BREACH)` and `marker.iterator` + walking parent references. Mostly unused outside of niche audit frameworks. +3. **Marker as a typed-tag**: a way to say "this is the audit log line" without + spelling out a `"category"` string. Same as a string-keyed boolean attr; the + "type-ness" is a thin gain. + +Backend routing is real, but it is the same operation as filtering on +`attrs["category"] == "AUDIT"`. The marker concept is *one type to learn* and +gives you a slightly cheaper filter (interned reference identity vs string +equality). Whether that's worth a separate type in the API depends on whether +the audience reflexively reaches for `MarkerFactory.getMarker("AUDIT")` (JVM +veterans: yes; everyone else: no). + +#### My opinion: keep markers in `lib_logging`, drop them in `lib_slogging` + +For an **SLF4J-shaped library**, removing markers would be a betrayal of the +concept "instantly familiar to anyone who has used SLF4J 2.x." Production +Logback configs filter on markers; existing code uses `MarkerFactory`; the +fluent builder's `.addMarker(...)` is the standard idiom. Dropping it forces +JVM-veteran users to rewrite their mental model, which is the explicit thing +this library is trying not to do. **`lib_logging` keeps multi-marker support.** + +For a **slog-shaped library**, importing markers would be the wrong move. slog's +single-concept attribute model is the half of its design that *is* worth +copying — markers are a separate type for what is otherwise just a string-keyed +flag, and the Go ecosystem deliberately voted against them. **`lib_slogging` +ships without markers.** Categorisation is `Attr.of("category", "AUDIT")` and +DAG queries are simulated by either dotted naming + prefix-match filters in the +handler, or by emitting both `category` and `parentCategory` attrs. + +If the comparison surfaces "we want markers in the slog-shaped library," that +would be a strong signal the slog design is *not* the right fit and the SLF4J +shape should win — because at that point the simplification motivating slog has +been undone. + +#### Side-by-side: how each library expresses common marker patterns + +The four cases below cover what marker users actually do in production. Each +shows the SLF4J idiom (`lib_logging`) and the slog equivalent (`lib_slogging`). + +##### Pattern 1 — single category tag ("this is an audit event") + +```ecstasy +// lib_logging +logger.atInfo() + .addMarker(MarkerFactory.getMarker("AUDIT")) + .log("user signed in"); +``` + +```ecstasy +// lib_slogging +logger.info("user signed in", [Attr.of("audit", True)]); +// or, if you prefer string-tag style: +logger.info("user signed in", [Attr.of("category", "AUDIT")]); +``` + +Cloud-side rendering (GCP Cloud Logging, JSON): + +```jsonc +// from lib_logging via the Logback Cloud Logging appender: +{ "severity": "INFO", "message": "user signed in", "labels": { "AUDIT": "true" } } + +// from lib_slogging via slog's JSONHandler: +{ "level": "INFO", "msg": "user signed in", "audit": true } +``` + +The SLF4J adapter shoves markers into `labels` (a string-only flat map). The +slog version emits the boolean directly into `jsonPayload`. **Both are +queryable in Cloud Logging.** A query for "all audit events" is +`labels.AUDIT="true"` in the SLF4J case and `jsonPayload.audit=true` in the +slog case — same operational outcome, different cell in the JSON. + +##### Pattern 2 — multiple markers on one event + +```ecstasy +// lib_logging — fluent builder accumulates +Marker AUDIT = MarkerFactory.getMarker("AUDIT"); +Marker SECURITY = MarkerFactory.getMarker("SECURITY"); + +logger.atInfo() + .addMarker(AUDIT) + .addMarker(SECURITY) + .log("login attempt"); +``` + +```ecstasy +// lib_slogging — just two attrs +logger.info("login attempt", [ + Attr.of("audit", True), + Attr.of("security", True), +]); +``` + +Multi-marker is what motivated W-1 in `OPEN_QUESTIONS.md`. In slog the +question doesn't arise — repeated attrs are the only shape. + +##### Pattern 3 — hierarchical relationships ("BREACH is a kind of SECURITY") + +This is the one case where markers genuinely add something attributes don't. +SLF4J markers can have child references; a filter on `SECURITY` catches every +descendant marker without enumerating them. + +```ecstasy +// lib_logging — DAG via Marker.add +Marker SECURITY = MarkerFactory.getMarker("SECURITY"); +Marker BREACH = MarkerFactory.getMarker("BREACH"); +SECURITY.add(BREACH); + +logger.atError().addMarker(BREACH).log("token leak detected"); + +// any filter configured to match SECURITY *also* matches BREACH: +assert SECURITY.contains(BREACH); +``` + +```ecstasy +// lib_slogging — emit both names; the filter does prefix matching +logger.error("token leak detected", [ + Attr.of("category", "SECURITY.BREACH"), + Attr.of("parentCategory", "SECURITY"), // optional, helps simple filters +]); +``` + +**This is the strongest argument for keeping markers in `lib_logging`** — +the DAG model is genuinely more expressive than the slog equivalent. **It is +also the marker feature production code rarely uses.** Most operational +filters are `category == "SECURITY.*"` glob matches at the cloud-product +level, not transitive containment queries inside the application. + +##### Pattern 4 — sink/handler-side routing ("send audit events to a separate file") + +```ecstasy +// lib_logging — a sink that filters on event.markers +const AuditFilteringLogSink(LogSink delegate, String requiredMarkerName) + implements LogSink { + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) = + marker?.containsName(requiredMarkerName) ?: False; + @Override + void log(LogEvent event) { + for (Marker m : event.markers) { + if (m.containsName(requiredMarkerName)) { + delegate.log(event); + return; + } + } + } +} + +new AuditFilteringLogSink(new FileLogSink("/var/log/audit.log"), "AUDIT"); +``` + +```ecstasy +// lib_slogging — a handler that filters on attrs +const AuditFilteringHandler(Handler delegate, String requiredKey) + implements Handler { + @Override + Boolean enabled(Level level) = delegate.enabled(level); + @Override + void handle(Record record) { + for (Attr a : record.attrs) { + if (a.key == requiredKey && a.value == True) { + delegate.handle(record); + return; + } + } + } + @Override Handler withAttrs(Attr[] attrs) = this; + @Override Handler withGroup(String name) = this; +} + +new AuditFilteringHandler(new FileHandler("/var/log/audit.log"), "audit"); +``` + +Both implementations are about the same length. The slog one is slightly +simpler (no nested `containsName` walk on the marker DAG). The SLF4J one is +more powerful (matches transitively). + +##### Pattern 5 — programmatic categorisation from a dynamic source + +When the category isn't known at compile time (e.g. it's read from a request +header): + +```ecstasy +// lib_logging +String catName = request.header("x-log-category") ?: "DEFAULT"; +Marker cat = MarkerFactory.getMarker(catName); // interned by name +logger.atInfo().addMarker(cat).log("event"); +``` + +```ecstasy +// lib_slogging +String catName = request.header("x-log-category") ?: "DEFAULT"; +logger.info("event", [Attr.of("category", catName)]); +``` + +The marker version interns by name, so repeated requests for `"AUDIT"` get +the same `Marker` instance — slightly cheaper to compare against in a filter. +The attribute version allocates a new `String` value each time. For most +production workloads this difference is too small to measure. + +#### A summary table + +| Concern | `lib_logging` (markers) | `lib_slogging` (attrs) | +|---|---|---| +| Single category | `addMarker(M)` | `Attr.of("k", v)` | +| Multiple categories | `addMarker(M1); addMarker(M2)` | `[Attr.of(...), Attr.of(...)]` | +| Hierarchical (DAG) categories | `parent.add(child); marker.contains(other)` | dotted naming + prefix filter | +| Sink-side routing | `marker?.containsName(X)` | `attr.key == X && attr.value == True` | +| Dynamic/runtime categorisation | `MarkerFactory.getMarker(name)` (interned) | `Attr.of("category", name)` | +| Identity comparison cost | reference equality on interned Marker | string equality on `key`/`value` | +| Cloud-side rendering | usually emitted as `labels.{name}` | usually emitted as a top-level JSON field | +| Familiar to Java/JVM teams | yes | no | +| Familiar to Go / cloud-native teams | no | yes | +| Forces a separate API concept | yes | no | + +#### Bottom line on markers + +`lib_slogging` does not have markers because *slog deliberately doesn't*. Every +marker use case maps onto attributes, with one corner the attribute model +loses (the DAG containment query). Whether that loss is worth keeping a +separate type for depends on the audience. + +The two libraries treat this differently on purpose: `lib_logging` carries +markers because SLF4J users expect them and the marker-aware Cloud Logging +adapter exists; `lib_slogging` omits markers because slog users expect them +to be omitted and the attribute-only API is the half of slog that's worth +copying. + +--- + +#### The three concepts in `lib_logging` + +SLF4J carries: + +1. `arguments` — positional substitution into `{}` slots in the message template. +2. `marker` — categorisation (audit, security, billing). +3. `keyValues` — explicit structured pairs (added in SLF4J 2.x). + +These were *bolted on over time* and the API shows it: the position of an `Object[]` +argument in the call signature is not the same as the position of a builder +`addKeyValue`. Some sinks (SimpleLogger) only use the message; some (Logback JSON +encoders) only use `keyValues` and ignore `arguments`. + +slog has *one* concept: `Attr`. Everything is an attribute. The message is a free +string; everything structured is an attribute; categories are attributes; the cause +of an error is an attribute. There is no "is this rendered into the message text or +emitted as a structured field" question — the handler decides per attr key. + +**Ecstasy fit.** slog wins on conceptual economy. SLF4J wins on familiarity and on +"you can have nicely-formatted free-text logs without writing handler code." A real +Ecstasy program will probably want both — which the SLF4J model gives explicitly and +the slog model gives via "the Text handler renders the message verbatim and appends +attrs." + +### 3.4 Message — `{}` template vs no interpolation + +SLF4J: `info("user {} did {}", [name, action])`. Lazy substitution: if the level is +disabled the `format` step is skipped. Familiar and concise. + +slog: `Info("user did action", "name", name, "action", action)` — no interpolation. +The handler decides how to render. Output is typically `msg="user did action" +name=alice action=login` for the text handler. + +**Ecstasy fit.** Both are easy. SLF4J's `{}` is more compact for human-readable +output; slog's structure-first is easier for machine consumption. The Ecstasy default +arguments and varargs make either ergonomic. + +### 3.5 Sink/handler shape — minimal vs richer + +`LogSink` has two methods. Everything (level filter, attr resolution, MDC capture, +formatting) happens above the sink. A new sink author writes `isEnabled` and `log` +and is done. + +slog `Handler` has four — the extras are `withAttrs(attrs)` and `withGroup(name)`, +which let the *handler* pre-resolve attached attributes when the user calls +`logger.With(...)`. This means a JSON handler can fold `requestId` into a +namespace prefix once, not every emission. It's a real performance win for +high-volume structured logging. + +**Ecstasy fit.** The slog handler shape is more work to implement but removes +allocation on the hot path for derived loggers. Whether that matters in Ecstasy +depends on workload. The lib_slogging skeleton currently exposes both methods so +the cost is a couple more lines per handler. + +### 3.6 Logger naming — hierarchical vs attribute-based + +SLF4J's `logger.named("payments")` gives you a `Logger` whose name is `". +payments"`. Hierarchical names are how Logback configuration ("set +`com.example.payments` to `Debug`") works. It's a category system that doesn't need +attrs because the *name* IS the category. + +slog has no notion of named loggers. You'd express "this is the payments component" +as an attribute that travels via `With`. Filtering happens by attr equality, not +name-prefix matching. + +**Ecstasy fit.** SLF4J names map cleanly to Ecstasy module / package names, which is +a feature: a `@Inject Logger` resolved with the enclosing module's qualified name is +how SLF4J users intuitively expect it to work. slog forfeits this for uniformity. + +### 3.7 Source location + +Out of scope for both libraries' v0, but worth noting: slog explicitly carries a +`pc`/`source` field on `Record` and the API contract says handlers may render it. +SLF4J leaves it to the sink. This isn't an architectural difference; it's a contract +difference. + +### 3.8 Async / batching + +Same shape in both: a wrapper handler/sink owns a bounded queue and drains on a +worker fiber. Lives in a follower module (`lib_logging_logback` / +`lib_slogging_async`), not the base library. + +### 3.9 Familiarity + +A subjective dimension that nonetheless matters for adoption. SLF4J is the dominant +JVM logging API; Java/Kotlin engineers reach for it without thinking. slog is the +modern Go default; cloud-native engineers reach for it. Ecstasy targets a developer +audience that will overlap heavily with both. + +--- + +## 4. Ecstasy idiom fit — where the languages bite + +### 4.1 `const` vs `service` for the building blocks + +In `lib_logging`: + +- `BasicLogger` is a `const` (so `@Inject Logger` lives on the caller's fiber, MDC + works). +- `MDC` is a `const` over `SharedContext`, copy-on-write. +- Sinks split: stateless ones (`NoopLogSink`, `ConsoleLogSink`) are `const`; + stateful ones (`MemoryLogSink`, `ListLogSink`) are `service`. + +In `lib_slogging`: + +- `Logger` is a `const` carrying `Handler handler` and `Attr[] attrs`. Derivation + via `with(...)` returns a new `const Logger` — naturally immutable. +- There is no MDC, so no `SharedContext`-flavoured cross-cutting state. +- Handlers split the same way: stateless adapters (`TextHandler`, `JSONHandler`, + `NopHandler`) are `const`; stateful collectors (`MemoryHandler`) are `service`. + +The slog model is *substantially* simpler on the const side because the absence of +MDC removes one of the two interactions with `SharedContext`. The other interaction +— "I want a logger that propagates through fibers without threading a parameter" — +is achievable with `SharedContext` if/when needed, but isn't required for the +core API. + +### 4.2 Fluent builder vs varargs + +SLF4J's fluent `.atInfo().setMessage(...).addKeyValue(...).log()` requires a +`LoggingEventBuilder` interface and a `BasicEventBuilder` implementation, plus a +no-op variant for short-circuiting disabled levels. That's three types of machinery +for one API surface. + +slog's `LogAttrs(level, msg, ...attrs)` doesn't need a builder at all — varargs of +typed `Attr` values cover the same ground. Fewer types, fewer allocations on the +disabled-level path (because the caller usually doesn't construct attrs unless the +level is enabled, via `if logger.Enabled(...) { ... }`). + +**Ecstasy fit.** The fluent builder feels Java-ish. The slog varargs feel closer to +how Ecstasy method signatures already work. But Ecstasy's default-argument support +already collapses many of SLF4J's fluent-builder use cases into one method call, so +the fluent surface is smaller in `lib_logging` than the SLF4J Java original. + +### 4.3 Marker subgraph vs attribute uniformity + +`Marker` in SLF4J/`lib_logging` is its own type with `add` (parent/child references) +and `contains` (transitive query). It also has identity (`MarkerFactory.getMarker` +interns). That's a small DAG type. + +slog has no marker concept. The same use cases (filter audit-only events) are +handled by `Attr.of("audit", True)` and a handler that filters on that attr. + +**Ecstasy fit.** Markers add a separate type; attributes don't. The marker model is +slightly more efficient (one interned reference vs a string equality check), but the +attribute model needs less surface area. + +### 4.4 Compiler-side default-name injection + +SLF4J expects `LoggerFactory.getLogger(MyClass.class)` to resolve to a class- or +module-named logger. `lib_logging` currently delegates that to a future compiler +change (Stage 4 in `RUNTIME_IMPLEMENTATION_PLAN.md`). Without it, `@Inject Logger` +gets a fixed-name root logger and you call `.named("...")` to derive children. + +slog has no such machinery — there is one process-wide default Logger and you derive +from it. No naming hierarchy, no compiler change. + +**Ecstasy fit.** slog removes a "do we make the compiler smarter?" question +entirely. SLF4J keeps the door open for the nicer ergonomics if the compiler change +lands. + +--- + +## 5. What we want reviewer feedback on + +1. **`MDC` worth keeping?** SLF4J's per-fiber MDC is the most opinionated piece of + this comparison. It costs ~50 lines of `SharedContext` machinery + a small + compiler ask. The slog approach (explicit `with(attrs)`) removes the implicit + context entirely. Which side of the implicit/explicit fork should an Ecstasy + library live on? + +2. **One sink interface or two?** Both designs end up with sinks/handlers split into + "stateless `const`" and "stateful `service`" implementations. We currently allow + both shapes under one interface. Should the language convention be one interface + per shape (forcing implementers to declare intent at the type level)? + +3. **Fluent builder pull-back.** SLF4J's `.atInfo()...` family is a third API + surface that pays for cases the per-level methods can't easily express. With + Ecstasy default arguments and varargs, the fluent builder may be unnecessary. + Should `lib_logging`'s fluent surface go away? + +4. **Hierarchical names.** Are class- / module-qualified hierarchical logger names + worth a compiler change to make `@Inject Logger` give you a class-named logger + automatically? Or is the slog model — one logger, attributes for everything else — + what we want for new Ecstasy code? + +5. **Markers vs attrs.** Do reviewers see real value in the marker DAG model (audit + marker, security marker, etc.) over a string-keyed attribute? `lib_logging` + carries the marker type today; `lib_slogging` does not. + +6. **Levels — closed enum vs extensible Int.** Five named levels are familiar but + inflexible; integer levels are open but lose canonicality. Which idiom should + Ecstasy converge on across logging, telemetry, and tracing libraries? + +7. **`@Inject` ergonomics.** Both libraries currently funnel through `@Inject Logger + logger;`. Do reviewers want this as the entry point, or should one of the two + libraries demonstrate a non-injection idiom (a top-level `Logger.default` const, + say) for comparison? + +8. **Source location.** slog captures it; SLF4J doesn't. Is this a v0 concern in + Ecstasy or can we defer to a follower release? + +9. **Performance baseline.** Neither library has been profiled. If reviewers want + benchmarks before deciding, name the workload (sustained 100k events/sec to a + no-op sink? burst with disabled levels? 95th-percentile latency at the call + site?) and we'll add it to the comparison. + +10. **Async / batching home.** Both libraries punt async to a follower module. Is + that the right call, or should one of the two ship a default async wrapper to + make "log to network" easier out of the box? + +11. **Should Ecstasy ship two logging libraries permanently?** The Java ecosystem has + two large facades (SLF4J, Log4j 2 API) and is by no means ergonomic for it. + Carrying both `lib_logging` and `lib_slogging` long-term has the same risk. The + intent of this experiment is to *pick one* and move on. We want reviewer input + on which. + +--- + +## 6. Tentative recommendation + +We lean — softly, fully expecting reviewer pushback — toward **`lib_slogging` as the +canonical Ecstasy logging library, with one borrowed feature from `lib_logging`**: +the per-fiber `SharedContext`-backed MDC, recast as `slogging.Context` and *also* +optional / never the only path. The reasoning, ranked: + +1. **Conceptual economy.** One concept (`Attr`) covering markers, structured KVs, + message slots, and exception causes is meaningfully simpler than three. Less for + newcomers to learn, less for sink authors to handle. +2. **Modern fit.** slog is the design that emerged after a decade of structured- + logging wins in Java/Go. SLF4J is the design that those wins were retrofitted + onto. The 2026-era language probably wants the post-retrofit shape. +3. **Less compiler ask.** No hierarchical naming, no fluent builder, no marker DAG. + Smaller surface to support. +4. **MDC is genuinely useful.** Threading a request-aware logger through a + call graph is real work that real codebases get wrong. A `slogging.Context` with + `withAttrs(...)` semantics, *opt in*, gives you the SLF4J win without the + "everything is implicit" cost. + +Counter-arguments worth taking seriously: + +- **Familiarity.** A great deal of Ecstasy's audience comes from JVM, where SLF4J is + reflexive. Forcing them to relearn a logging API for no reason other than design + preference is friction. +- **Markers and named loggers are not just "structured data dressed up funny" — they + enable per-category configuration that's awkward to express as attribute filters + in a config file.** +- **Fluent builders genuinely shine when the keys aren't statically known** — e.g. + building a log line from a request whose fields vary. Varargs handle this less + cleanly. + +The recommendation is therefore tentative. We expect to revisit it once the +reviewers — particularly the XTC language team — weigh in on questions Q-D1 through +Q-D6 in `OPEN_QUESTIONS.md`. + +--- + +## 7. What lives in this PR + +This branch (`lagergren/logging`) contains: + +- `lib_logging/` — the SLF4J-shaped library, near-feature-complete (47 unit tests + passing). Tracking list of remaining items: `OPEN_QUESTIONS.md` § "SLF4J-style + library work not yet implemented". +- `lib_slogging/` — the slog-shaped library, currently a **skeleton** (interfaces + + one stub `TextHandler`). Full implementation gated on reviewer feedback on this + document. +- This document — the design comparison and the explicit list of reviewer questions. +- `OPEN_QUESTIONS.md` — the unified question list (all "Q-D*" items added in this PR + call out language-design questions specifically). +- `DESIGN.md` — the SLF4J-side design, now with the resolved sink-type rule. + +We do not propose merging until reviewers have weighed in on at least Q-D6 (which +API shape) and Q-D1 (sink interface convention). diff --git a/doc/logging/OPEN_QUESTIONS.md b/doc/logging/OPEN_QUESTIONS.md index 5a29ae6c63..3cd54992a9 100644 --- a/doc/logging/OPEN_QUESTIONS.md +++ b/doc/logging/OPEN_QUESTIONS.md @@ -19,7 +19,7 @@ When an "addressed by the plan" item lands in code, delete its row. | # | Question | Resolution | |---|---|---| -| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | `nMainInjector.supplierOf` falls back to a wildcard `(loggerType, "*")` entry. See [Stage 1.4](RUNTIME_IMPLEMENTATION_PLAN.md#14--wildcard-name-resolution-in-nmaininjector). | +| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](RUNTIME_IMPLEMENTATION_PLAN.md#14--single-fixed-name-supplier-no-wildcard-injection). | | 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](RUNTIME_IMPLEMENTATION_PLAN.md#stage-4--compiler-side-default-name-optional-but-high-impact). | | 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](RUNTIME_IMPLEMENTATION_PLAN.md#31--decide-the-scope). | | 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](RUNTIME_IMPLEMENTATION_PLAN.md#21--real-messageformatterformat). | @@ -33,33 +33,36 @@ When an "addressed by the plan" item lands in code, delete its row. These need a decision before the API is locked in. Numbered to preserve traceability to earlier discussion. -### 4. Multiple markers per event +### 4. Multiple markers per event — *resolved* -SLF4J 2.x's `LoggingEventBuilder.addMarker` accepts repeated calls and stores all of -them on the event. `BasicEventBuilder` currently keeps only the most recently added -one. +**Resolution.** Implemented option A. `LogEvent.markers: Marker[]` is now the canonical +field (see `LogEvent.x`); a `LogEvent.marker: Marker?` convenience accessor returns +`markers[0]` for sinks that only surface a single category line. `BasicEventBuilder` +accumulates per `addMarker(...)` call and freezes the array before crossing the sink +boundary. Per-level methods (`info`/`warn`/...) still take a single optional `Marker` +and wrap it; multi-marker is reachable through the fluent builder. SPI-level +`LogSink.isEnabled(...)` keeps its single-marker signature (matches SLF4J 1.x's +`Logger.isEnabledFor(Marker)`); the "primary" marker is `markers[0]`. Tests: +`MarkerTest.shouldPropagateMultipleMarkersThroughFluentBuilder`, +`MarkerTest.shouldEmitNoMarkersWhenNoneAttached`. -**Options:** - -- **A. Match SLF4J — list of markers per event.** Requires `LogEvent.marker: Marker?` - → `LogEvent.markers: Marker[]`. Affects every sink that reads markers. -- **B. Keep one marker, document the limitation.** Sinks that need multiple markers - can use child references on a single marker. - -**Recommendation:** A. Cheap to do now (a few lines, plus updating two test -assertions); breaking later is much more work. +### 6. `const` vs `service` for sinks — *resolved* -### 6. Service vs class for sinks +**Resolution.** `LogSink` is an interface; implementations choose between `const` and +`service` according to the rule documented in `DESIGN.md` ("Sink type: `const` vs +`service`"): -Most `LogSink` implementations are services (mutable thread-safe state); some are not. - -**Options:** +- stateless forwarders / pure adapters → `const` +- stateful collectors, resource owners, async workers → `service` -- **A. Recommend, don't require.** `LogSink` stays an interface; impls choose. -- **B. Require.** Force `LogSink extends Service` in the interface signature. +`BasicLogger` is a `const`, so every concrete sink must be `Passable` (either +`immutable` or a `service`). Forcing `LogSink extends Service` in the interface +signature was rejected because it would prohibit the legitimate stateless cases +(`NoopLogSink`, `ConsoleLogSink`) and contradicts the `class ConsoleAppender` / +`class ConsoleLog` pattern already used in `lib_ecstasy/src/main/x/ecstasy/io/`. -**Recommendation:** A. Forcing service-hood breaks `NoopLogSink` and complicates test -helpers; users who need state will reach for `service` naturally. +This rule is checked for parity against patterns in `platform/common`, +`platform/host`, and `lib_xunit_engine` — see DESIGN.md for the citations. ### 7. Async / batched sinks @@ -74,34 +77,26 @@ Where does the async-wrapper sink live? **Recommendation:** B. The configurable backend is the natural home for async; basics ship in v0 unchanged. -### 8. Per-container override convenience - -Ecstasy's container model gives each container its own injector. A host could in -principle want a different sink for one nested container. - -**Options:** - -- **A. Document the pattern.** "Configure the child container's injector explicitly." - No API addition. -- **B. Provide a helper.** Something like `ContainerLoggingConfig.set(child, sink)`. - -**Recommendation:** A. Wait for demand. +### 8. Per-container override convenience — *resolved* -### 11. Defensive copy of caller-supplied `Object[] arguments` +**Resolution.** Adopted option A — document the pattern, add no API. A host that +wants a nested container to use a different sink configures the child container's +injector explicitly to resolve the `("logger", Logger)` key against the alternate +sink. Pattern documented in `DESIGN.md` § "Per-container sink override". No +`lib_logging`-side helper, deliberately: per-container resource overrides are the +host runtime / injector library's job, not the logging library's. -`BasicEventBuilder` already converts its accumulated `args` to `Constant`-mode (frozen) -before crossing the sink boundary. The per-level methods (`info`, `debug`, …) accept -`Object[]` from the caller directly. If the caller mutates that array between the -return of `info(...)` and an async sink consuming the event, the sink may see the -mutation. +### 11. Defensive copy of caller-supplied `Object[] arguments` — *resolved* -**Options:** - -- **A. Defensive copy at the `BasicLogger.emit` boundary.** One allocation per - emission. -- **B. Document that callers must not mutate the array.** Match SLF4J's posture. - -**Recommendation:** B for v0. Reconsider if/when async sinks become common. +**Resolution.** Adopted option B for v0: callers contractually must not mutate the +`arguments` array between the return of `info(...)` and any sink consuming the +resulting `LogEvent`. Matches SLF4J's posture; documented as a one-paragraph policy +comment in `BasicLogger.emitWith` (the only entry point that receives caller-owned +args). `BasicEventBuilder` is already safe by construction because it freezes its +internally-accumulated `args` to `Constant`-mode before crossing the sink boundary. +Reconsider if/when async sinks become a default — at that point the cost of the +defensive copy is justified by the larger window during which the caller can +mutate. ### 12. Compiler/tooling support for log statements @@ -113,6 +108,161 @@ feature, not a library feature. --- +## Questions for the XTC language / runtime designers + +These are points where we made a call but want explicit confirmation from the people +who own the language semantics, before locking the API. + +### Q-D1. Is "`const` if stateless / `service` if stateful with shared mutable state" the right pattern for an Ecstasy SPI boundary? + +We landed on a rule (see DESIGN.md, "Sink type") that lets `LogSink` accept both shapes, +mirroring the way `lib_ecstasy/src/main/x/ecstasy/io/ConsoleAppender.x` is a `class` +while `service ErrorLog` and `service ConsoleExecutionListener` exist elsewhere. The +alternative is forcing every implementation to be one shape (e.g. `LogSink extends +Service`) and accepting the loss of `const` stateless sinks. + +**Question:** is permitting both shapes idiomatic for an XDK SPI, or does the language +team prefer SPI interfaces that constrain to one shape for clarity? + +### Q-D2. `@Inject` inside a `const` — is this fully supported? + +`BasicLogger` is a `const` and declares `@Inject Clock clock; @Inject MDC mdc;`. +`ConsoleLogSink` is a `const` and declares `@Inject Console console;`. This works in +practice today, but the docs are thin on whether `@Inject` resolution semantics are +identical inside a `const` versus a `service` (timing, fiber affinity, caching). + +**Question:** is `@Inject` inside a `const` first-class, or is there a subtlety we +should know about (e.g. resolved at construction vs at first access; affinity to the +constructing fiber vs the calling fiber; freezing behaviour after construction)? + +### Q-D3. Calling a service from a `const` field — boundary semantics + +`const BasicLogger` holds a `LogSink sink` field. When that sink is a `service`, every +`sink.log(event)` is an inter-service hop with implicit freeze of the arguments. We +adopted "freeze the marker before the first sink hop" as a workaround +(`BasicLogger.emitWith`) because the diagnostic wording ("Property not freezable" / +"mutable object cannot be used as an argument to a service call") is hard to map back +to "the value crossed an implicit service boundary." + +**Question:** is there a more idiomatic way to express "this `const` adapts a service +under the hood, and the service boundary cost is paid at this method call"? Should +the compiler give us a clearer diagnostic when a value would need to be `Passable` to +cross such a boundary? + +### Q-D4. Per-fiber `MDC` via `SharedContext` — are we using it as intended? + +`MDC` is implemented as `const MDC` storing `immutable Map` in a +single static `ecstasy.SharedContext`, with copy-on-write derivation on every mutation +(`put / remove / clear`). This relies on (a) `const` methods running on the caller's +fiber so `mapContext.hasValue()` resolves against that fiber's tokens; and (b) the +runtime's clone-on-write fiber-token map for sync calls so child fibers see parent +state without being able to mutate it back. + +**Question:** does this match the intended use of `SharedContext` for fiber-local +context propagation, or should we be using a different primitive (a built-in +`MDC`-equivalent, a service-injected ledger, fiber-local slots) for SLF4J-grade MDC +semantics? + +### Q-D5. Removing the `RTLogger.java` wrapper — is there a class of "injection-only" +runtime resources where the right answer is "construct the const directly, don't wrap"? + +The original POC went through `xRTLogger.INSTANCE` (a service template wrapping a +`Logger`). That wrapper crossed a fiber boundary and severed the caller's MDC tokens. +Removing the wrapper and registering `BasicLogger` directly as the resource (see +`NativeContainer.ensureLogger`) fixed the problem and simplified the runtime side. + +**Question:** is this generally the right pattern when (a) the injected type is a +`const`, (b) it has no per-call native state, and (c) we want fiber-local context +visibility to survive injection? If so, should `nMainInjector` grow a +`registerConstResource(...)` helper to make this the documented happy path? + +### Q-D6. SLF4J vs slog as the API shape — design preference + +We're maintaining two parallel libraries (`lib_logging` modeled after SLF4J 2.x and +`lib_slogging` modeled after Go's `slog`) so the design tradeoffs can be evaluated +side-by-side. See `LIB_LOGGING_VS_LIB_SLOGGING.md` for the comparison and the explicit +list of things we want reviewer feedback on. + +**Question:** which API style is a better long-term fit for Ecstasy idioms (services, +consts, `SharedContext`, fluent builders), and is there appetite for shipping both, +shipping one, or shipping a third synthesis? + +### Q-D7. Cross-module default-argument resolution on `const` constructors + +Calling `new ConsoleLogSink()` from the `TestLogger` module fails with +`Unresolvable function "void construct()"` even though `ConsoleLogSink` was +declared as `const ConsoleLogSink(Level rootLevel = Info)`. Same shape failed +for `new BasicLogger(name, sink)` against +`const BasicLogger(String name, LogSink sink, LoggerRegistry? registry = Null)`. +Adding explicit no-arg / shorter-arg `construct(...)` forms that delegate to the +primary constructor fixes it; same-module callers (the convenience constructor +inside `BasicLogger.x` itself, the unit tests inside `LoggingTest`) work fine +without the explicit forms. + +We worked around it by writing every "shorter form" explicitly — see +`BasicLogger.x`, `ConsoleLogSink.x`, `Logger.x`, `TextHandler.x`, `JSONHandler.x`. +Each has a `Why:` comment naming the workaround. + +**Question:** is the synthesis of "callable" forms from default-argument +constructors only available within the declaring module, or is this a bug? If +intended, the `construct(Level rootLevel = Info)` syntax is misleading because +it reads as "this defaults to Info" but practically requires explicit +delegating constructors for cross-module ergonomics. If a bug, fixing it would +remove the explicit-delegation clutter from every const that wants a no-arg +form. + +--- + +## SLF4J-style library (`lib_logging`) — work not yet implemented + +Tracking list so the SLF4J-shaped library stays comparable to `lib_slogging` in +feature scope. Each item is a concrete deliverable with a one-line summary. + +| # | Item | Notes | +|---|---|---| +| W-1 | Multiple markers per event | `LogEvent.marker: Marker?` → `LogEvent.markers: Marker[]`; accumulate in `BasicEventBuilder`; render `[markers=A,B]` in `ConsoleLogSink`. Tracked as task #2; see open question 4 above. | +| W-2 | Logger interning in `Logger.named` | **Done.** Implemented `service LoggerRegistry(LogSink sink)` keyed by logger name; `BasicLogger.registry: LoggerRegistry?` (optional, defaulted `Null` so unattached loggers stay allocation-light); `LoggerFactory` lazily constructs a registry over its `defaultSink` and routes both `getLogger(...)` and `named(...)` through it. Tests: `NamedLoggerTest.shouldInternChildrenWhenRegistryAttached`, `NamedLoggerTest.shouldNotInternWhenRegistryAbsent`. | +| W-3 | Real `MessageFormatter` | **Done.** Full SLF4J ParameterFormatter parity: `{}` substitution, `\{}` literal escape, `\\{}` double-escape, trailing-throwable promotion (with explicit-cause precedence enforced in `BasicLogger.emit`), defensive `safeToString`. 12 tests in `MessageFormatterTest`. The "stub" claim in earlier docs was stale. | +| W-4 | Compiler-side default name from module | `@Inject Logger logger;` (no resourceName) should fall back to the enclosing module's qualified name; see plan Stage 4. | +| W-5 | Async / batched sink (`AsyncLogSink`) | Bounded queue + worker fiber; lives in `lib_logging_logback` not in the base lib. Open question 7. | +| W-6 | Logback-shaped backend (`lib_logging_logback`) | Configuration-driven sink with per-logger thresholds, multiple appenders, layouts, filters. Sketched in `LOGBACK_INTEGRATION.md`. | +| W-7 | Native bridge (`RTLogbackSink.java`) | Optional escape hatch wrapping real Logback via `javatools_jitbridge`. Sketched in `NATIVE_BRIDGE.md`. | +| W-8 | Per-container override convenience | Helper for child containers wanting a different sink. Open question 8. | +| W-9 | Defensive copy of caller-supplied `Object[] arguments` | Open question 11. Decision: B (document, no copy) for v0. Listed here so it's not forgotten if v0 changes. | +| W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | +| W-11 | Doc cleanup | `DESIGN.md` "What isn't here yet" still lists items that have since landed (RTLogger removed, MessageFormatter partial, tests now exist); `RUNTIME_IMPLEMENTATION_PLAN.md` and `INJECTED_LOGGER_EXAMPLE.md` may reference the now-removed `xRTLogger`/`RTLogger.x`. | + +The same tracking shape will live for `lib_slogging` in +`LIB_LOGGING_VS_LIB_SLOGGING.md` so reviewers can see the two libraries reach feature +parity at the same waterline before we ask "which API do you prefer?" + +### Implementation tiers + +The W-items above are grouped into three tiers so we can decide how far to push +`lib_logging` before reviewer feedback determines which library survives. + +| Tier | Scope | Items | Approx. cost | +|---|---|---|---| +| **1** | Close visible loose ends. No new design choices. | W-1 (multi-marker), W-11 (doc cleanup) | ~1 hour | +| **2** | Make `lib_logging` fully comparable as a shippable library. | Tier 1 + W-2 (logger interning, needs a `LoggerRegistry` service since `BasicLogger` is `const`) + W-3 (real `MessageFormatter` with `{}` substitution + trailing-throwable promotion) | ~half a day | +| **3** | Out of scope for this PR — separate change(s). | W-4 (compiler default-name), W-5 (`AsyncLogSink`), W-6 (`lib_logging_logback`), W-7 (native bridge), W-8 (per-container override), W-9 (defensive copy), W-10 (linter) | per-item; multiple PRs | + +**Decision (revised):** Tier 1 *and* Tier 2 land in this PR for both libraries. +Reviewers asked for two **comparable** libraries — i.e. parity in implementation +maturity, not just in design intent — so they can give a feedback judgment without +discounting one side as "skeleton vs production." Tier 3 items remain out of scope +(separate PRs). + +The `lib_slogging` parity translation: + +| `lib_logging` (W) | `lib_slogging` analogue | +|---|---| +| W-1 multi-marker on event | n/a — slog uses repeated attrs, already supported by `Attr[]` | +| W-2 logger interning in `Logger.named` | n/a — slog has no hierarchical names; ergonomic `Logger.with(...)` already returns derived loggers | +| W-3 real `MessageFormatter` with `{}` substitution | n/a — slog has no message templating; `TextHandler` / `JSONHandler` formatting completeness instead | +| W-11 doc cleanup | mirrored: keep `lib_slogging` doc references in sync | +| (test suite parity) | mirror `LoggingTest` → `SLoggingTest`: per-level routing, fluent-equivalent (`logger.with`), level thresholds, exception carriage, structured attrs, group nesting | + ## Decisions required to land in this order 1. **MDC scope (#3)** — must be made before Stage 1.3 of the plan. Without an answer diff --git a/doc/logging/README.md b/doc/logging/README.md index 6f31b0f541..dad0f2baa3 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -1,7 +1,16 @@ -# Ecstasy `lib_logging` — start here +# Ecstasy logging — start here + +This directory holds the design and discussion docs for the two experimental Ecstasy +logging libraries on the `lagergren/logging` branch: + +- **`lib_logging`** — modelled on **SLF4J 2.x + Logback** (the dominant JVM ecosystem). +- **`lib_slogging`** — modelled on **Go's `log/slog`** (the modern cloud-native idiom). + +Both libraries cover the same end-to-end logging functionality with different API +shapes, so reviewers can compare them side-by-side. **Start with +[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** for the +comparison and the explicit list of what we want reviewer feedback on. -This directory holds the design and discussion docs for `lib_logging`, the experimental -SLF4J-shaped logging library being added to the XDK on the `lagergren/logging` branch. If you are looking at the project for the first time, **this README is the right place to land first**: it explains what the project is, who it is for, and where to go next depending on what you want to do. @@ -77,12 +86,27 @@ The docs are organised by question. Pick the one that matches what you want to k Logback* in via the JIT bridge? Investigation, evidence, trade-offs, and a recommendation. Short answer: feasible but not the primary path. +### "Two libraries — what's the comparison?" + +- **[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — the design + comparison between `lib_logging` (SLF4J-shaped) and `lib_slogging` (slog-shaped). + Per-axis table, deep dives on markers and on `MDC` vs `With`, side-by-side code + for the common patterns, Ecstasy-idiom fit notes, the **eleven explicit reviewer + questions** we want feedback on, and a tentative recommendation. **Read this + first** if you are reviewing the project. +- **[`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md)** — explains why API choice is + the entry point to GCP / AWS / Azure observability ecosystems (drop-in adapters, + schema conventions, dashboards-that-already-work). Written for readers whose + deployment intuition is "ship a CLI installer" and need a tour of cloud-native + logging conventions. + ### "Could the design have looked different?" - **[`ALTERNATIVE_DESIGN_SLOG_STYLE.md`](ALTERNATIVE_DESIGN_SLOG_STYLE.md)** — - exploratory: what `lib_logging` would look like if modeled on Go's `log/slog` - instead of SLF4J. Code examples only, no actual implementation. Documents what - we'd be giving up and gaining if we ever revisit the choice. + exploratory predecessor to `lib_slogging`: what an SLF4J-shaped library would look + like if modeled on Go's `log/slog` instead. Now superseded by the actual + `lib_slogging` module + `LIB_LOGGING_VS_LIB_SLOGGING.md` comparison; kept for + history. ### "What's still uncertain?" diff --git a/lib_slogging/build.gradle.kts b/lib_slogging/build.gradle.kts new file mode 100644 index 0000000000..09b9e8bdee --- /dev/null +++ b/lib_slogging/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.xtc) +} + +dependencies { + xdkJavaTools(libs.javatools) + xtcModule(libs.xdk.ecstasy) + xtcModuleTest(libs.javatools.bridge) + xtcModuleTest(libs.xdk.xunit.engine) +} diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x new file mode 100644 index 0000000000..386b1b0f0e --- /dev/null +++ b/lib_slogging/src/main/x/slogging.x @@ -0,0 +1,60 @@ +/** + * Corresponds, as a whole module, to Go 1.21+'s `log/slog` package — plus a minimum + * viable handler so the library is usable out of the box. Provided as a parallel design + * exercise alongside [`logging.xtclang.org`][lib_logging] (the SLF4J-shaped library) so + * reviewers can compare the two designs side-by-side. + * + * Ecstasy structured-logging library, slog-shaped. + * + * The module is intentionally shaped to be _instantly familiar_ to anyone who has used + * `log/slog` in Go: a top-level `Logger` carrying a `Handler` and a list of attached + * `Attr`s, derived loggers via `Logger.with(attrs)`, no thread-local context (you carry + * a logger), no "marker" concept (categorisation is just attributes), open-ended + * integer-based `Level` values. + * + * The primary entry point is injection: + * + * @Inject Logger logger; + * + * Acquiring loggers without injection is supported by constructing one directly: + * + * Logger logger = new Logger(new TextHandler()); + * + * Derived loggers are pure construction: + * + * Logger requestLogger = logger.with([ + * Attr.of("requestId", req.id), + * Attr.of("user", req.userId), + * ]); + * + * # Status + * + * **This module is currently a skeleton** — interfaces, the `Logger` const, and stub + * handlers (`TextHandler`, `NopHandler`, `MemoryHandler`, `JSONHandler`). Full + * implementation is gated on reviewer feedback in + * `doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md` so we don't sink effort into both shapes + * before deciding which one Ecstasy should adopt. + * + * # API / Implementation boundary + * + * The public API consists of: + * - [Logger] — the user-facing concrete `const` + * - [Level] — open-ended severity (Int + String label) + * - [Attr] — single key/value pair carried as structured data + * - [Record] — immutable record of a single log call (LogEvent equivalent) + * - [Handler] — the SPI that backends implement (LogSink equivalent) + * + * The implementation side of that boundary contains: + * - [TextHandler] — default human-readable text handler, writes via `@Inject Console` + * - [JSONHandler] — JSON-Lines structured handler (skeleton) + * - [NopHandler] — drops every record + * - [MemoryHandler] — captures records in memory; useful in tests + * + * # See also + * + * doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md — the design comparison document + * doc/logging/OPEN_QUESTIONS.md — list of reviewer questions (Q-D6) + * lib_logging/src/main/x/logging.x — the SLF4J-shaped sibling library + */ +module slogging.xtclang.org { +} diff --git a/lib_slogging/src/main/x/slogging/Attr.x b/lib_slogging/src/main/x/slogging/Attr.x new file mode 100644 index 0000000000..92ec0daf6f --- /dev/null +++ b/lib_slogging/src/main/x/slogging/Attr.x @@ -0,0 +1,48 @@ +/** + * Corresponds to `log/slog.Attr` (`go.dev/src/log/slog/attr.go`). The single carrier + * for structured data attached to a log record. + * + * Every piece of structured data — what SLF4J would split across `arguments`, `marker`, + * and `keyValues` — is expressed as one of these. A `Logger` carries an `Attr[]` of + * "always include these" attributes; the per-call `info(message, extra)` adds more for + * that call only. + * + * Attr.of("user", "alice") + * Attr.of("count", 42) + * Attr.of("audit", True) + * Attr.of("err", e) + * + * # Value typing + * + * Go's `slog.Value` is a closed discriminated union (`KindString`, `KindInt64`, + * `KindBool`, `KindDuration`, `KindTime`, `KindAny`, `KindGroup`, `KindLogValuer`). + * In Ecstasy we accept any `Object` here and let the handler do the case split via + * `value.is(...)`. This is closer to how the SLF4J library carries `Map` + * and avoids inventing a parallel kind-tag enum. The trade-off — slightly more work in + * each handler — is documented in `LIB_LOGGING_VS_LIB_SLOGGING.md` § 3.5. + * + * Values must be `Passable` (`immutable` or a service) because `Attr` is a `const` and + * its fields are auto-frozen on construction. Trying to put a mutable class instance in + * here will fail with the standard "not freezable" diagnostic. + * + * # Groups + * + * Setting `value` to an `Attr[]` represents a nested group, equivalent to slog's + * `slog.Group("user", slog.String("id", "u_1"), slog.String("role", "admin"))`. The + * handler decides how to render groups (JSON nests, text concatenates with dots). + */ +const Attr(String key, Object value) { + + /** + * Convenience factory. Mirrors slog's `slog.String("k", v)` / `slog.Int(...)` — + * the per-type Go helpers exist there because Go has no generic / `Object` parent + * type; in Ecstasy a single `of` is sufficient because everything inherits `Object`. + */ + static Attr of(String key, Object value) = new Attr(key, value); + + /** + * Construct a nested group attribute. Equivalent to slog's + * `slog.Group(name, attrs...)`. + */ + static Attr group(String name, Attr[] attrs) = new Attr(name, attrs); +} diff --git a/lib_slogging/src/main/x/slogging/Handler.x b/lib_slogging/src/main/x/slogging/Handler.x new file mode 100644 index 0000000000..dea08f67ad --- /dev/null +++ b/lib_slogging/src/main/x/slogging/Handler.x @@ -0,0 +1,66 @@ +/** + * Corresponds to `log/slog.Handler` (`go.dev/src/log/slog/handler.go`). The SPI a + * logging backend implements — analogous to the SLF4J-shaped library's + * [logging.LogSink], but with a richer surface that lets the handler pre-resolve + * derivations. + * + * # The contract + * + * Boolean enabled(Level level) — cheap fast-path filter + * void handle(Record record) — emit the record + * Handler withAttrs(Attr[] attrs) — derive a pre-bound handler + * Handler withGroup(String name) — derive a name-prefixed handler + * + * The two extra methods exist so that when user code does `logger.with(attrs)` once and + * then logs millions of records through the derived logger, the handler can do the + * "merge attrs into the namespace" work *once* at derivation time rather than on every + * `handle` call. + * + * Stateless / forwarder handlers — `TextHandler`, `NopHandler` — return `this` from + * both derivation methods. Only handlers that actually pre-resolve attrs need a richer + * implementation. + * + * # Choosing between `const` and `service` for an implementation + * + * Same rule as `lib_logging`'s [logging.LogSink]: + * + * - `const` — for stateless forwarders / pure adapters. Examples: `TextHandler`, + * `NopHandler`, `JSONHandler`. Construction-time configuration only. + * - `service` — for handlers that carry mutable state shared across fibers. Examples: + * `MemoryHandler` (collects records), a future `FileHandler` (owns a + * writer), a future `AsyncHandler` (owns a worker queue). + * + * `Logger` is a `const` and references its `handler` through this interface, so every + * concrete `Handler` must be `Passable` (`immutable` or a `service`). The full rule and + * the citations to `lib_xunit_engine` / `platform/common` are in `DESIGN.md` ("Sink + * type: `const` vs `service`") — same rule, both libraries. + */ +interface Handler { + + /** + * Cheap level check. Should be safe to call on every emission. + */ + Boolean enabled(Level level); + + /** + * Emit the supplied record. + * + * Must not throw under normal conditions; handlers that experience an internal + * failure (disk full, remote endpoint down) are expected to degrade gracefully — + * typically by writing to standard error — rather than propagate. + */ + void handle(Record record); + + /** + * Return a handler that carries the supplied attributes pre-bound. Stateless + * handlers can return `this`; structured handlers may pre-render the attrs into a + * cached prefix. + */ + Handler withAttrs(Attr[] attrs); + + /** + * Return a handler that namespaces subsequent attributes under the supplied group + * name. Stateless handlers can return `this`. + */ + Handler withGroup(String name); +} diff --git a/lib_slogging/src/main/x/slogging/JSONHandler.x b/lib_slogging/src/main/x/slogging/JSONHandler.x new file mode 100644 index 0000000000..8383c74f00 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/JSONHandler.x @@ -0,0 +1,65 @@ +/** + * Corresponds to `log/slog.JSONHandler` (`go.dev/src/log/slog/json_handler.go`). The + * structured-output handler — emits one JSON object per record on its own line. + * + * # Skeleton status + * + * Stub implementation only — emits a tiny hand-rolled JSON, not RFC-compliant. Real + * implementation will use `lib_json` once we wire it as a dependency. Listed here so + * the comparison document can refer to it as a real type, not just a future name. + */ +const JSONHandler(Level rootLevel, String groupPrefix) + implements Handler { + + /** + * No-arg convenience. See parallel comment on `lib_slogging.TextHandler`. + */ + construct() { + construct JSONHandler(Level.Info, ""); + } + + /** + * Single-arg convenience: configurable threshold, no group prefix. + */ + construct(Level rootLevel) { + construct JSONHandler(rootLevel, ""); + } + + @Inject Console console; + + @Override + Boolean enabled(Level level) { + return level.severity >= rootLevel.severity; + } + + @Override + void handle(Record record) { + // TODO(impl): use lib_json. The skeleton here renders a flat, NOT-rfc-compliant + // approximation so the comparison document has something to point at. The + // real implementation will: + // - properly escape strings; + // - nest groups instead of dot-flattening; + // - render timestamps as RFC3339; + // - render exception traces structurally. + StringBuffer buf = new StringBuffer(); + buf.append("{\"time\":\"").append(record.time.toString()).append("\"") + .append(",\"level\":\"").append(record.level.label).append("\"") + .append(",\"msg\":\"").append(record.message).append("\""); + for (Attr a : record.attrs) { + buf.append(",\""); + String key = groupPrefix == "" ? a.key : $"{groupPrefix}.{a.key}"; + buf.append(key).append("\":\"").append(a.value.toString()).append("\""); + } + buf.append("}"); + console.print(buf.toString()); + } + + @Override + Handler withAttrs(Attr[] attrs) = this; + + @Override + Handler withGroup(String name) { + String combined = groupPrefix == "" ? name : $"{groupPrefix}.{name}"; + return new JSONHandler(rootLevel, combined); + } +} diff --git a/lib_slogging/src/main/x/slogging/Level.x b/lib_slogging/src/main/x/slogging/Level.x new file mode 100644 index 0000000000..151227b1aa --- /dev/null +++ b/lib_slogging/src/main/x/slogging/Level.x @@ -0,0 +1,49 @@ +/** + * Corresponds to `log/slog.Level` (`go.dev/src/log/slog/level.go`). Severity is a plain + * integer; well-known constants are named (`DEBUG=-4`, `INFO=0`, `WARN=4`, `ERROR=8`) + * but callers may construct intermediate or beyond-canonical levels. + * + * The `slog`-style level model: an open integer line, in contrast to the SLF4J-shaped + * sibling library's closed `Trace / Debug / Info / Warn / Error / Off` enum. The + * canonical four levels are spaced four apart so callers can interject custom levels + * (`Notice` between `Info` and `Warn`, `Critical` past `Error`) without colliding with + * a library's choices. + * + * Level NOTICE = new Level(2, "NOTICE"); + * logger.log(NOTICE, "user signed in"); + * + * For symmetry with the SLF4J library the four canonical names are exposed as `static` + * constants on this type. Comparisons go through `severity` directly. + * + * if (level.severity >= threshold.severity) { ... } + */ +const Level(Int severity, String label) + implements Orderable { + + /** + * Canonical levels. Spacing matches `log/slog`'s `LevelDebug=-4`, `LevelInfo=0`, + * `LevelWarn=4`, `LevelError=8` — four apart so user-defined levels (e.g. `NOTICE` + * between `Info` and `Warn`) can be interjected without re-spacing. + */ + static Level Debug = new Level(-4, "DEBUG"); + static Level Info = new Level( 0, "INFO"); + static Level Warn = new Level( 4, "WARN"); + static Level Error = new Level( 8, "ERROR"); + + /** + * `True` iff a record at this level should be emitted given the supplied + * `threshold`. Matches the SLF4J library's [logging.Level.enabledAtThreshold] for + * caller convenience. + */ + Boolean enabledAtThreshold(Level threshold) { + return severity >= threshold.severity; + } + + @Override + static Ordered compare(CompileType a, CompileType b) { + return a.severity <=> b.severity; + } + + @Override + String toString() = label; +} diff --git a/lib_slogging/src/main/x/slogging/Logger.x b/lib_slogging/src/main/x/slogging/Logger.x new file mode 100644 index 0000000000..e9727c0276 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/Logger.x @@ -0,0 +1,125 @@ +/** + * Corresponds to `log/slog.Logger` (`go.dev/src/log/slog/logger.go`). The user-facing + * concrete logger. + * + * Unlike the SLF4J-shaped sibling library — where [logging.Logger] is an interface and + * [logging.BasicLogger] is its `const` implementation — `Logger` here is concrete from + * the start. Polymorphism lives at the [Handler] boundary; the `Logger` itself is a + * thin combinator that builds a [Record] and forwards it. + * + * `Logger` is a `const` so: + * - methods run on the caller's fiber (no service-boundary hop on the hot path); + * - derivation via [with] / [withGroup] is just construction, no mutation; + * - it is `Passable`, so it can flow across fibers (fields of services, parameters + * of service calls). + * + * # The user-facing API + * + * logger.debug("computed", [Attr.of("ms", 12)]); + * logger.info("processing", [Attr.of("path", req.path)]); + * logger.warn("retrying", [Attr.of("attempt", 3)]); + * logger.error("failed", [Attr.of("err", e)]); + * logger.log(NOTICE, "user signed in", [...]); + * + * Logger reqLog = logger.with([ + * Attr.of("requestId", req.id), + * Attr.of("user", req.userId), + * ]); + * + * Logger groupLog = logger.withGroup("payments"); + * groupLog.info("charged", [Attr.of("amount", 1099)]); + * // text handler: 2026-04-29 INFO charged payments.amount=1099 + * // json handler: {"level":"INFO","msg":"charged","payments":{"amount":1099}} + * + * # Skeleton status + * + * The `log` method is implemented (level-check fast path, attr concatenation, record + * construction, forward to handler). Source-location capture is not. There is no + * `LogAttrs(ctx, level, msg, attrs...)` form yet — the per-level methods cover the same + * ground in v0; `ctx`-style propagation is the open Q-D6.b discussion in + * `OPEN_QUESTIONS.md`. + */ +const Logger(Handler handler, Attr[] attrs) + implements Orderable { + + @Inject Clock clock; + + /** + * Convenience: no-arg, wires a fresh [TextHandler] and empty attrs. This is the + * constructor the runtime would call for `@Inject Logger logger;` once the native + * resource supplier is registered (parallel to the SLF4J library's + * `NativeContainer.ensureLogger`). + * + * Explicit delegating constructors (rather than `attrs = []` default on the + * primary form) for the same reason as the SLF4J side: cross-module + * default-argument resolution on `const` constructors does not always synthesise + * the shorter form. See `lib_logging.BasicLogger`. + */ + construct() { + construct Logger(new TextHandler(), []); + } + + /** + * Convenience: handler only, empty attrs. + */ + construct(Handler handler) { + construct Logger(handler, []); + } + + @RO Boolean debugEnabled.get() = handler.enabled(Level.Debug); + @RO Boolean infoEnabled.get() = handler.enabled(Level.Info); + @RO Boolean warnEnabled.get() = handler.enabled(Level.Warn); + @RO Boolean errorEnabled.get() = handler.enabled(Level.Error); + + Boolean enabled(Level level) = handler.enabled(level); + + void debug(String message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Debug, message, extra, cause); + + void info(String message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Info, message, extra, cause); + + void warn(String message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Warn, message, extra, cause); + + void error(String message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Error, message, extra, cause); + + /** + * Open-level emission. Use for custom levels. + */ + void log(Level level, String message, Attr[] extra = [], Exception? cause = Null) { + if (!handler.enabled(level)) { + return; + } + Attr[] merged = extra.empty ? attrs : (attrs + extra).toArray(Constant); + handler.handle(new Record( + time = clock.now, + message = message, + level = level, + attrs = merged, + exception = cause, + )); + } + + /** + * Return a derived logger that carries the supplied attributes plus this one's. + * Equivalent to `slog.Logger.With(...)`. The handler is also asked to derive + * (`handler.withAttrs(more)`) so structured handlers can pre-resolve. + */ + Logger with(Attr[] more) { + if (more.empty) { + return this; + } + Attr[] combined = attrs.empty ? more.toArray(Constant) : (attrs + more).toArray(Constant); + return new Logger(handler.withAttrs(more), combined); + } + + /** + * Return a derived logger whose subsequent attributes are namespaced under + * `groupName`. Equivalent to `slog.Logger.WithGroup(...)`. + */ + Logger withGroup(String groupName) { + return new Logger(handler.withGroup(groupName), attrs); + } +} diff --git a/lib_slogging/src/main/x/slogging/MemoryHandler.x b/lib_slogging/src/main/x/slogging/MemoryHandler.x new file mode 100644 index 0000000000..8823470a68 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/MemoryHandler.x @@ -0,0 +1,64 @@ +/** + * Test-oriented handler — captures every emitted record in an in-memory list for + * later assertion. Direct equivalent of `lib_logging`'s `MemoryLogSink`, and + * structurally identical to `slog`'s `slogtest`-style helpers in Go. + * + * MemoryHandler h = new MemoryHandler(); + * Logger logger = new Logger(h); + * logger.info("processed", [Attr.of("count", 42)]); + * assert h.records.size == 1; + * assert h.records[0].level == Level.Info; + * + * # Why this handler is a `service` + * + * It accumulates records. The backing array is mutated on every `handle()` call and + * the same instance is shared across the logger-under-test fiber and the assertion + * fiber. Same reasoning as `lib_logging`'s `MemoryLogSink`. See + * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + */ +service MemoryHandler + implements Handler { + + public/private Level rootLevel = Level.Debug; + + /** + * Mutable backing storage. Internal — Ecstasy forbids returning a mutable array + * from a service call, so we expose [records] as an immutable snapshot below. + */ + private Record[] recordList = new Record[]; + + /** + * Immutable snapshot of the captured records, in emission order. Each access + * copies — same pattern as `lib_logging.MemoryLogSink.events`. + */ + @RO Record[] records.get() { + return recordList.toArray(Constant); + } + + @Override + Boolean enabled(Level level) { + return level.severity >= rootLevel.severity; + } + + @Override + void handle(Record record) { + recordList.add(record); + } + + @Override + Handler withAttrs(Attr[] attrs) { + // Share the same underlying recordList so derived loggers' records show up in + // the same place — matches slogtest behaviour and what tests intuitively want. + return this; + } + + @Override + Handler withGroup(String name) = this; + + /** + * Discard all captured records. + */ + void reset() { + recordList.clear(); + } +} diff --git a/lib_slogging/src/main/x/slogging/NopHandler.x b/lib_slogging/src/main/x/slogging/NopHandler.x new file mode 100644 index 0000000000..c3393f938f --- /dev/null +++ b/lib_slogging/src/main/x/slogging/NopHandler.x @@ -0,0 +1,21 @@ +/** + * Corresponds to slog's idiomatic "discard" handler — typically constructed as + * `slog.New(slog.NewTextHandler(io.Discard, nil))` in Go, but a single-purpose type is + * cleaner in Ecstasy. + * + * Stateless — drops every record. `enabled` always returns `False` so callers that + * respect the check can skip building attribute arrays entirely. + * + * # Why this handler is a `const` + * + * Identical reasoning to `lib_logging`'s `NoopLogSink`: no state, no shared mutation, + * pure forwarder. See `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + */ +const NopHandler + implements Handler { + + @Override Boolean enabled(Level level) = False; + @Override void handle(Record record) {} + @Override Handler withAttrs(Attr[] attrs) = this; + @Override Handler withGroup(String name) = this; +} diff --git a/lib_slogging/src/main/x/slogging/Record.x b/lib_slogging/src/main/x/slogging/Record.x new file mode 100644 index 0000000000..98b1dac898 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/Record.x @@ -0,0 +1,26 @@ +/** + * Corresponds to `log/slog.Record` (`go.dev/src/log/slog/record.go`). The immutable + * unit of "one log call's worth of data" handed to a [Handler]. + * + * Equivalent to the SLF4J-shaped library's [logging.LogEvent], but flattened: there is + * no separate `marker` or `keyValues` — categorisation and structured fields all live + * in `attrs`. + * + * The `attrs` array carries both the "always-on" attributes attached to the source + * `Logger` (via `Logger.with(...)`) and the per-call extras passed to `info(...)` etc., + * concatenated by the `Logger`. Handlers see one flat list. + * + * Source-location capture (`sourceFile`, `sourceLine`) is opt-in. The default is + * `(Null, -1)`, meaning "not captured." A future `Logger` configuration switch will + * turn it on. + */ +const Record( + Time time, + String message, + Level level, + Attr[] attrs, + Exception? exception = Null, + String? sourceFile = Null, + Int sourceLine = -1, + String threadName = "", + ); diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x new file mode 100644 index 0000000000..6c03f85e19 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -0,0 +1,111 @@ +/** + * Corresponds to `log/slog.TextHandler` (`go.dev/src/log/slog/text_handler.go`). The + * default human-readable handler — emits one line per record in `key=value` format. + * + * Format: + * + * 2026-04-29T11:23:45.012Z INFO msg="processed request" requestId=r_42 user=u_3 + * + * If the record carries an exception, it is rendered on the following line. + * + * # Skeleton status + * + * The implementation here is intentionally minimal — enough to demonstrate the slog + * shape end to end (`Logger.with(...)`, attribute folding into namespaced keys, level + * threshold filtering) but not yet a production formatter. Specifically, no escaping + * of `=` / `"` in attribute values, no group nesting collapse beyond one level, no + * timestamp formatting options. Tracked in `OPEN_QUESTIONS.md` § "lib_slogging work + * not yet implemented" once the parity table is added. + * + * # Why this handler is a `const`, not a `service` + * + * Same rule as `lib_logging`'s `ConsoleLogSink`: stateless forwarder over `@Inject + * Console`. Threshold fixed at construction. No accumulation, no shared mutable state. + * See `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`") for the full rule. + */ +const TextHandler(Level rootLevel, String groupPrefix) + implements Handler { + + /** + * No-arg convenience: threshold `Info`, no group prefix. See parallel comment on + * `lib_logging.ConsoleLogSink` for why we use explicit delegating constructors + * instead of default-arg synthesis. + */ + construct() { + construct TextHandler(Level.Info, ""); + } + + /** + * Single-arg convenience: configurable threshold, no group prefix. + */ + construct(Level rootLevel) { + construct TextHandler(rootLevel, ""); + } + + @Inject Console console; + + @Override + Boolean enabled(Level level) { + return level.severity >= rootLevel.severity; + } + + @Override + void handle(Record record) { + StringBuffer buf = new StringBuffer(); + buf.append(record.time.toString()) + .append(' ') + .append(record.level.label.leftJustify(5, ' ')) + .append(' ') + .append("msg=") + .append(quote(record.message)); + + for (Attr a : record.attrs) { + buf.append(' '); + renderAttr(buf, groupPrefix, a); + } + + console.print(buf.toString()); + + if (Exception e ?= record.exception) { + // TODO(impl): pretty-print stack frames; for now rely on Exception.toString(). + console.print(e.toString()); + } + } + + @Override + Handler withAttrs(Attr[] attrs) { + // Stateless variant: the Logger already carries the attrs list and merges them + // into every record before calling handle(). A richer handler would pre-resolve + // here. + return this; + } + + @Override + Handler withGroup(String name) { + String combined = groupPrefix == "" ? name : $"{groupPrefix}.{name}"; + return new TextHandler(rootLevel, combined); + } + + /** + * Render one attr `key=value`, prefixing with the active group path if any. Nested + * groups (an `Attr` whose value is `Attr[]`) are flattened with a dot separator — + * matches `slog.TextHandler`. + */ + private void renderAttr(StringBuffer buf, String prefix, Attr a) { + String key = prefix == "" ? a.key : $"{prefix}.{a.key}"; + if (a.value.is(Attr[])) { + Boolean first = True; + for (Attr child : a.value.as(Attr[])) { + if (!first) { + buf.append(' '); + } + first = False; + renderAttr(buf, key, child); + } + } else { + buf.append(key).append('=').append(a.value.toString()); + } + } + + private String quote(String s) = $"\"{s}\""; +} diff --git a/lib_slogging/src/test/x/SLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest.x new file mode 100644 index 0000000000..57f169e0af --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest.x @@ -0,0 +1,20 @@ +/** + * Unit tests for the `slogging.xtclang.org` library — the slog-shaped sibling of + * `lib_logging`. The structure mirrors `LoggingTest` so a reviewer comparing the two + * libraries can see equivalent test coverage at the same waterline. + * + * The tests build small, in-memory `Handler` implementations (`ListHandler`, + * `MemoryHandler`) and assert that: + * - the level check fast-path elides emission of disabled records; + * - the per-level methods (`debug`, `info`, `warn`, `error`) and the open `log(level, + * ...)` route correctly; + * - exceptions arrive at the handler intact; + * - `Logger.with(...)` accumulates always-on attributes that flow into every record; + * - `Logger.withGroup(...)` namespaces subsequent attributes; + * - `Attr.group(name, [...])` renders nested structure; + * - custom `Level` values comparable to / between the canonical four work. + */ +module SLoggingTest { + package slogging import slogging.xtclang.org; + package xunit import xunit.xtclang.org; +} diff --git a/lib_slogging/src/test/x/SLoggingTest/AttrTest.x b/lib_slogging/src/test/x/SLoggingTest/AttrTest.x new file mode 100644 index 0000000000..db99403144 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/AttrTest.x @@ -0,0 +1,42 @@ +import slogging.Attr; + +/** + * Tests for `Attr` — the single carrier for structured data. + */ +class AttrTest { + + @Test + void shouldCarryStringValue() { + Attr a = Attr.of("user", "alice"); + assert a.key == "user"; + assert a.value == "alice"; + } + + @Test + void shouldCarryIntegerValue() { + Attr a = Attr.of("count", 42); + assert a.key == "count"; + assert a.value == 42; + } + + @Test + void shouldCarryBooleanValue() { + Attr a = Attr.of("audit", True); + assert a.key == "audit"; + assert a.value == True; + } + + @Test + void shouldCarryNestedGroup() { + Attr group = Attr.group("user", [ + Attr.of("id", "u_1"), + Attr.of("role", "admin"), + ]); + assert group.key == "user"; + assert group.value.is(Attr[]); + Attr[] children = group.value.as(Attr[]); + assert children.size == 2; + assert children[0].key == "id" && children[0].value == "u_1"; + assert children[1].key == "role" && children[1].value == "admin"; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x new file mode 100644 index 0000000000..509df3b9d7 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x @@ -0,0 +1,65 @@ +import slogging.Attr; +import slogging.Level; +import slogging.Logger; + +/** + * Tests for the per-level emission methods. Mirrors `LoggingTest.EmissionTest`. + */ +class EmissionTest { + + @Test + void shouldRouteInfo() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + + logger.info("hello", [Attr.of("count", 1)]); + + assert handler.records.size == 1; + assert handler.records[0].level == Level.Info; + assert handler.records[0].message == "hello"; + assert handler.records[0].attrs.size == 1; + assert handler.records[0].attrs[0].key == "count"; + } + + @Test + void shouldRouteAllLevels() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + + logger.debug("d"); + logger.info ("i"); + logger.warn ("w"); + logger.error("e"); + + assert handler.records.size == 4; + assert handler.records[0].level == Level.Debug; + assert handler.records[1].level == Level.Info; + assert handler.records[2].level == Level.Warn; + assert handler.records[3].level == Level.Error; + } + + @Test + void shouldCarryException() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + Exception boom = new Exception("boom"); + + logger.error("failed", [Attr.of("status", 500)], cause=boom); + + assert handler.records.size == 1; + assert handler.records[0].exception?.text == "boom" : assert; + } + + @Test + void shouldRouteCustomLevelThroughLog() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + Level notice = new Level(2, "NOTICE"); + + logger.log(notice, "user signed in"); + + assert handler.records.size == 1; + assert handler.records[0].level == notice; + assert handler.records[0].message == "user signed in"; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x b/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x new file mode 100644 index 0000000000..e39b0f2934 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x @@ -0,0 +1,38 @@ +import slogging.Attr; +import slogging.Level; +import slogging.Logger; + +/** + * Tests that the handler's `enabled(level)` fast-path drops events below threshold. + * Mirrors `LoggingTest.LevelCheckTest`. + */ +class LevelCheckTest { + + @Test + void shouldDropRecordsBelowThreshold() { + ListHandler handler = new ListHandler(); + handler.setLevel(Level.Warn); + Logger logger = new Logger(handler); + + logger.debug("d"); + logger.info ("i"); + logger.warn ("w"); + logger.error("e"); + + assert handler.records.size == 2; + assert handler.records[0].level == Level.Warn; + assert handler.records[1].level == Level.Error; + } + + @Test + void shouldReportEnabledFlagsConsistentlyWithThreshold() { + ListHandler handler = new ListHandler(); + handler.setLevel(Level.Info); + Logger logger = new Logger(handler); + + assert !logger.debugEnabled; + assert logger.infoEnabled; + assert logger.warnEnabled; + assert logger.errorEnabled; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/LevelTest.x b/lib_slogging/src/test/x/SLoggingTest/LevelTest.x new file mode 100644 index 0000000000..831426e79b --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/LevelTest.x @@ -0,0 +1,45 @@ +import slogging.Level; + +/** + * Tests for `Level` — the open-ended integer-severity model. + * + * Mirrors `LoggingTest.LevelTest` but exercises the slog-style additions: custom levels + * between or beyond the canonical four, ordered comparison via `severity`. + */ +class LevelTest { + + @Test + void shouldOrderCanonicalLevels() { + assert Level.Debug.severity < Level.Info.severity; + assert Level.Info.severity < Level.Warn.severity; + assert Level.Warn.severity < Level.Error.severity; + } + + @Test + void shouldSupportCustomLevels() { + // The canonical levels are spaced four apart precisely so callers can interject. + Level notice = new Level(2, "NOTICE"); + Level critical = new Level(12, "CRITICAL"); + + assert notice.severity > Level.Info.severity; + assert notice.severity < Level.Warn.severity; + assert critical.severity > Level.Error.severity; + } + + @Test + void shouldRespectThresholdSemantics() { + Level threshold = Level.Warn; + assert Level.Error.enabledAtThreshold(threshold); + assert Level.Warn.enabledAtThreshold(threshold); + assert !Level.Info.enabledAtThreshold(threshold); + assert !Level.Debug.enabledAtThreshold(threshold); + } + + @Test + void shouldRenderViaLabel() { + assert Level.Debug.toString() == "DEBUG"; + assert Level.Info.toString() == "INFO"; + assert Level.Warn.toString() == "WARN"; + assert Level.Error.toString() == "ERROR"; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x new file mode 100644 index 0000000000..60b8d22c74 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x @@ -0,0 +1,59 @@ +import slogging.Attr; +import slogging.Handler; +import slogging.Level; +import slogging.Record; + +/** + * Test-only handler that captures every emitted record in a list. Used as a stand-in + * for a "real" handler so tests can assert exactly what the `Logger` chose to emit, in + * order. Mirrors the `ListLogSink` helper in `LoggingTest`. + * + * `rootLevel` defaults to `Debug` so by default every emitted record is captured. + * + * Modelled as a `service` because it carries mutable state (the records list) shared + * across the fiber-under-test and the assertion code. See + * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + */ +service ListHandler + implements Handler { + + public/private Level rootLevel = slogging.Level.Debug; + + /** + * Mutable backing storage. Internal — Ecstasy forbids returning a mutable array + * from a service call, so we expose [records] as an immutable snapshot below. + */ + private Record[] recordList = new Record[]; + + /** + * Immutable snapshot of the captured records, in emission order. Each access + * copies — same pattern as `lib_logging.MemoryLogSink.events`. + */ + @RO Record[] records.get() { + return recordList.toArray(Constant); + } + + @Override + Boolean enabled(Level level) { + return level.severity >= rootLevel.severity; + } + + @Override + void handle(Record record) { + recordList.add(record); + } + + @Override + Handler withAttrs(Attr[] attrs) = this; + + @Override + Handler withGroup(String name) = this; + + void setLevel(Level level) { + rootLevel = level; + } + + void reset() { + recordList.clear(); + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x new file mode 100644 index 0000000000..41441c075b --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x @@ -0,0 +1,38 @@ +import slogging.Attr; +import slogging.Level; +import slogging.Logger; +import slogging.MemoryHandler; + +/** + * Tests the shipped `MemoryHandler` (the test-helper handler that's part of the + * library's own surface, parallel to `lib_logging.MemoryLogSink`). + */ +class MemoryHandlerTest { + + @Test + void shouldCaptureRecordWithAttrs() { + MemoryHandler handler = new MemoryHandler(); + Logger logger = new Logger(handler); + + logger.info("processed", [Attr.of("count", 42)]); + + assert handler.records.size == 1; + assert handler.records[0].level == Level.Info; + assert handler.records[0].message == "processed"; + assert handler.records[0].attrs.size == 1; + assert handler.records[0].attrs[0].key == "count"; + } + + @Test + void shouldResetClearsRecords() { + MemoryHandler handler = new MemoryHandler(); + Logger logger = new Logger(handler); + + logger.info("a"); + logger.info("b"); + assert handler.records.size == 2; + + handler.reset(); + assert handler.records.empty; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x new file mode 100644 index 0000000000..350eceba5c --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x @@ -0,0 +1,42 @@ +import slogging.Attr; +import slogging.Logger; +import slogging.TextHandler; + +/** + * Tests `Logger.withGroup(name)` — slog's namespace-prefix derivation. A grouped logger + * produces records whose attributes the *handler* renders under the supplied prefix. + * + * No equivalent exists in `lib_logging` (SLF4J's hierarchical logger names cover a + * different concern — the *logger's identity*, not the *attributes' namespace*). + */ +class WithGroupTest { + + @Test + void shouldReturnDistinctLoggerInstance() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler); + Logger grouped = base.withGroup("payments"); + + // The Logger is a const; "distinctness" here is about API shape: the two refer + // to different logger instances even when the underlying handler is the same. + assert &base != &grouped; + } + + @Test + void shouldNotMutateAttrsOnGroupedLogger() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler).with([Attr.of("env", "prod")]); + Logger grouped = base.withGroup("payments"); + + grouped.info("charged", [Attr.of("amount", 1099)]); + + // The grouped logger forwards the same attrs the parent carried, plus the call's. + // The "namespace" effect is something the *handler* applies on render — the + // record's attrs are unchanged. + assert handler.records.size == 1; + Attr[] attrs = handler.records[0].attrs; + assert attrs.size == 2; + assert attrs[0].key == "env"; + assert attrs[1].key == "amount"; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/WithTest.x b/lib_slogging/src/test/x/SLoggingTest/WithTest.x new file mode 100644 index 0000000000..2c19a0669c --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/WithTest.x @@ -0,0 +1,78 @@ +import slogging.Attr; +import slogging.Logger; + +/** + * Tests the slog `With(...)` derivation idiom: a logger derived from another carries + * the parent's attributes plus its own, and these flow into every record emitted via + * the derived logger. + * + * No direct equivalent in `lib_logging` (SLF4J uses MDC for this). The closest test in + * the SLF4J library is `MDCTest.shouldAttachSnapshotToLogEvent`. + */ +class WithTest { + + @Test + void shouldCarryAttachedAttrsIntoRecord() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler); + + Logger derived = base.with([ + Attr.of("requestId", "r_42"), + Attr.of("user", "u_3"), + ]); + + derived.info("processing"); + + assert handler.records.size == 1; + Attr[] attrs = handler.records[0].attrs; + assert attrs.size == 2; + assert attrs[0].key == "requestId" && attrs[0].value == "r_42"; + assert attrs[1].key == "user" && attrs[1].value == "u_3"; + } + + @Test + void shouldConcatenateAcrossWithChains() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler); + + Logger withA = base.with([Attr.of("a", 1)]); + Logger withB = withA.with([Attr.of("b", 2)]); + + withB.info("event"); + + assert handler.records.size == 1; + Attr[] attrs = handler.records[0].attrs; + assert attrs.size == 2; + assert attrs[0].key == "a"; + assert attrs[1].key == "b"; + } + + @Test + void shouldCombineAttachedAttrsWithCallTimeExtras() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler); + + Logger reqLog = base.with([Attr.of("requestId", "r_99")]); + + reqLog.info("processing", [Attr.of("path", "/api")]); + + Attr[] attrs = handler.records[0].attrs; + assert attrs.size == 2; + assert attrs[0].key == "requestId"; + assert attrs[1].key == "path"; + } + + @Test + void shouldNotAffectParentLogger() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler); + + Logger derived = base.with([Attr.of("k", "v")]); + + base.info("from base"); + derived.info("from derived"); + + assert handler.records[0].attrs.empty; + assert handler.records[1].attrs.size == 1; + } +} diff --git a/xdk/settings.gradle.kts b/xdk/settings.gradle.kts index df8e552f52..0427ec71c8 100644 --- a/xdk/settings.gradle.kts +++ b/xdk/settings.gradle.kts @@ -27,6 +27,7 @@ listOf( "lib_json", "lib_jsondb", "lib_logging", + "lib_slogging", "lib_oodb", "lib_sec", "lib_web", From a1666f48e172aa87940f1e0c14d089ffe68e18a4 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Fri, 1 May 2026 22:26:18 +0200 Subject: [PATCH 06/28] doc/logging: restructure as a navigable doc tree The doc directory had grown to 19 files of overlapping scope with no clear entry point. Anyone landing in it had to scan filenames to figure out what to read first. Restructured so a new reader has one obvious place to start and a clear path forward. - README.md: rewritten as the actual "start here" entry point. Opens with the two-libraries framing, points at LIB_LOGGING_VS_LIB_SLOGGING.md as the headline, gives explicit reading paths for seven different audiences (reviewer, SLF4J user, slog user, language designer, sink/handler author, cloud-deployer, migrator), and ends with a one-line-per-doc index of the full tree. Verified every link resolves. - archive/: new subdir for truly superseded docs. - archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md (was top-level): superseded by the actual lib_slogging module + LIB_LOGGING_VS_LIB_SLOGGING.md. - archive/PLAN.md (was top-level): the original plan from when the branch was a stub; steps 1-8 have all landed and the remainder is tracked as Tier 3 in OPEN_QUESTIONS.md. Both archived files carry a one-paragraph status banner pointing at the live replacement. - Every other top-level doc now has a "_See also [README.md] for the full doc index_" footer so a reader landing mid-tree can find their way back to navigation. The bodies of these docs are unchanged. The result: 17 active top-level docs (down from 19), 2 archived, and a README that orients you in under five minutes regardless of where you came from. --- doc/logging/CLOUD_INTEGRATION.md | 5 + doc/logging/CUSTOM_SINKS.md | 5 + doc/logging/DESIGN.md | 5 + doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md | 5 + doc/logging/INJECTED_LOGGER_EXAMPLE.md | 5 + doc/logging/LAZY_LOGGING.md | 5 + doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md | 5 + doc/logging/LOGBACK_INTEGRATION.md | 5 + doc/logging/NATIVE_BRIDGE.md | 5 + doc/logging/OPEN_QUESTIONS.md | 5 + .../PLATFORM_AND_EXAMPLES_ADAPTATION.md | 5 + doc/logging/README.md | 348 +++++++++--------- doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md | 5 + doc/logging/SLF4J_PARITY.md | 5 + doc/logging/STRUCTURED_LOGGING.md | 5 + doc/logging/WHY_SLF4J_AND_INJECTION.md | 5 + doc/logging/XDK_ALIGNMENT.md | 5 + .../ALTERNATIVE_DESIGN_SLOG_STYLE.md | 5 + doc/logging/{ => archive}/PLAN.md | 7 + 19 files changed, 264 insertions(+), 176 deletions(-) rename doc/logging/{ => archive}/ALTERNATIVE_DESIGN_SLOG_STYLE.md (97%) rename doc/logging/{ => archive}/PLAN.md (93%) diff --git a/doc/logging/CLOUD_INTEGRATION.md b/doc/logging/CLOUD_INTEGRATION.md index 3f3fd3ff0a..9396a7b34b 100644 --- a/doc/logging/CLOUD_INTEGRATION.md +++ b/doc/logging/CLOUD_INTEGRATION.md @@ -233,3 +233,8 @@ shipping into an empty ecosystem and writing the integrations yourself. This document exists so that decision is made with that context, not without it. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/CUSTOM_SINKS.md b/doc/logging/CUSTOM_SINKS.md index 902c2dfdfd..6074f2787b 100644 --- a/doc/logging/CUSTOM_SINKS.md +++ b/doc/logging/CUSTOM_SINKS.md @@ -288,3 +288,8 @@ service JsonLineLogSink - [ ] Output is line-oriented or otherwise framed so `tail -f` is meaningful. - [ ] The exception is rendered fully — message, stack frames, causes. - [ ] If asynchronous, a flush mechanism exists for clean shutdown. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/DESIGN.md b/doc/logging/DESIGN.md index d4260e8bb8..e56cbfc1f2 100644 --- a/doc/logging/DESIGN.md +++ b/doc/logging/DESIGN.md @@ -264,3 +264,8 @@ earlier interpose service `xRTLogger.java` was removed in favour of constructing `OPEN_QUESTIONS.md`). The real `MessageFormatter` is implemented (12 tests in `MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (51 passing as of this commit). + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md b/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md index 91637111b8..a677e67b41 100644 --- a/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md +++ b/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md @@ -344,3 +344,8 @@ logger.info("escaped \\{}", ["x"]); ``` The semantics are the same — `MessageFormatter` is a port of SLF4J's reference behaviour. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/INJECTED_LOGGER_EXAMPLE.md b/doc/logging/INJECTED_LOGGER_EXAMPLE.md index f4cd11d051..7c117e633e 100644 --- a/doc/logging/INJECTED_LOGGER_EXAMPLE.md +++ b/doc/logging/INJECTED_LOGGER_EXAMPLE.md @@ -245,3 +245,8 @@ module Today { | Attach context for this request | `mdc.put("requestId", id);` | That covers ~95% of real-world logging use. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/LAZY_LOGGING.md b/doc/logging/LAZY_LOGGING.md index 371d271a38..ce3c167ebf 100644 --- a/doc/logging/LAZY_LOGGING.md +++ b/doc/logging/LAZY_LOGGING.md @@ -241,3 +241,8 @@ decisions: standing in the languages that have first-class lambdas. This is consistent with picking Option 1 and only revisiting if data demands it. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md b/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md index e9d0676c2b..b123e15b73 100644 --- a/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md +++ b/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md @@ -745,3 +745,8 @@ This branch (`lagergren/logging`) contains: We do not propose merging until reviewers have weighed in on at least Q-D6 (which API shape) and Q-D1 (sink interface convention). + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/LOGBACK_INTEGRATION.md b/doc/logging/LOGBACK_INTEGRATION.md index 78424beea8..ee7a8bc2ba 100644 --- a/doc/logging/LOGBACK_INTEGRATION.md +++ b/doc/logging/LOGBACK_INTEGRATION.md @@ -286,3 +286,8 @@ These are explicitly later: These exist in Java Logback. They are infrequently used and can be added incrementally without API changes. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/NATIVE_BRIDGE.md b/doc/logging/NATIVE_BRIDGE.md index 5bd7ac276e..a216e0acbe 100644 --- a/doc/logging/NATIVE_BRIDGE.md +++ b/doc/logging/NATIVE_BRIDGE.md @@ -225,3 +225,8 @@ Ecstasy, optionally ship a native bridge for legacy interop. The most important property is that none of these decisions break caller code. The `Logger` interface and the `LogSink` boundary make all of this swappable. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/OPEN_QUESTIONS.md b/doc/logging/OPEN_QUESTIONS.md index 3cd54992a9..f5bd284371 100644 --- a/doc/logging/OPEN_QUESTIONS.md +++ b/doc/logging/OPEN_QUESTIONS.md @@ -274,3 +274,8 @@ The `lib_slogging` parity translation: needs to know whether to copy or not. The remaining still-open items (#6, #7, #8, #12) can wait until there are real users. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md b/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md index cf858998f5..93d3c1a4d0 100644 --- a/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md +++ b/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md @@ -270,3 +270,8 @@ PR against the platform repo migrating `kernel.x`, `auth/OAuthProvider.x`, and in one chunk and validates the API end-to-end on real code. The examples repo can follow opportunistically, file by file — there is no urgency, and the gains there are educational rather than operational. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/README.md b/doc/logging/README.md index dad0f2baa3..0c4e539745 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -1,193 +1,189 @@ # Ecstasy logging — start here -This directory holds the design and discussion docs for the two experimental Ecstasy -logging libraries on the `lagergren/logging` branch: - -- **`lib_logging`** — modelled on **SLF4J 2.x + Logback** (the dominant JVM ecosystem). -- **`lib_slogging`** — modelled on **Go's `log/slog`** (the modern cloud-native idiom). - -Both libraries cover the same end-to-end logging functionality with different API -shapes, so reviewers can compare them side-by-side. **Start with -[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** for the -comparison and the explicit list of what we want reviewer feedback on. - -If you are looking at the project for the first time, **this README is the right place -to land first**: it explains what the project is, who it is for, and where to go next -depending on what you want to do. - -## What `lib_logging` is in one paragraph - -A logging library for Ecstasy that is *instantly familiar* to anyone who has used SLF4J -2.x in Java — same level set, same `{}`-substituted parameterized messages, markers, -MDC, the SLF4J 2.x fluent event builder — but acquired the Ecstasy way: by -`@Inject Logger logger;`. The default sink writes to the platform `Console`; a clean -SPI (`LogSink`) is the API/impl boundary so richer backends (file, network, JSON, -configuration-driven Logback-style) can be dropped in without touching caller code. - -The corresponding code is in [`lib_logging/`](../../lib_logging) at the repo root. - -## What you'll find in this directory - -The docs are organised by question. Pick the one that matches what you want to know: - -### "Does this look like the rest of the XDK?" - -- **[`XDK_ALIGNMENT.md`](XDK_ALIGNMENT.md)** — surveys other XDK libs (`lib_ecstasy`, - `lib_cli`, `lib_crypto`, `lib_json`, etc.) and their conventions for injection, the - shareable-value mixins (`Freezable`/`Stringable`/`Hashable`/`Orderable`), and the - `const`/`service`/`class` split — and shows how `lib_logging` was aligned. - -### "What is this and why are we doing it?" - -- **[`PLAN.md`](PLAN.md)** — the master plan: scope, non-goals, ordering of work, - validation checklist. Read this first if you want the executive summary. -- **[`WHY_SLF4J_AND_INJECTION.md`](WHY_SLF4J_AND_INJECTION.md)** — the rationale - doc. Argues at length why modeling on SLF4J *and* on Ecstasy injection is the right - combination, and why the alternatives (JUL, log4j, slog, "roll our own") all fall - short in specific, predictable ways. -- **[`DESIGN.md`](DESIGN.md)** — the architecture. Module layout, the API↔impl - boundary, what each type does, how the runtime injection flows. - -### "How does this look in practice?" - -- **[`INJECTED_LOGGER_EXAMPLE.md`](INJECTED_LOGGER_EXAMPLE.md)** — a complete working - example of `@Inject Logger` end to end, with a runnable companion at - `manualTests/src/main/x/TestLogger.x`. Read this before everything else if you - just want to see what it looks like. -- **[`ECSTASY_VS_JAVA_EXAMPLES.md`](ECSTASY_VS_JAVA_EXAMPLES.md)** — for every - feature, a working SLF4J Java example followed immediately by the equivalent - Ecstasy code. Use this to onboard SLF4J users. -- **[`SLF4J_PARITY.md`](SLF4J_PARITY.md)** — exhaustive type-by-type and method-by- - method mapping from SLF4J 2.x to `lib_logging`. Reference document, not narrative. -- **[`PLATFORM_AND_EXAMPLES_ADAPTATION.md`](PLATFORM_AND_EXAMPLES_ADAPTATION.md)** — - surveys the existing `~/src/platform` and `~/src/examples` repos and shows - concretely how their current ad-hoc `console.print($"... Info :")` patterns would - migrate to `lib_logging`. - -### "How would I extend it?" - -- **[`CUSTOM_SINKS.md`](CUSTOM_SINKS.md)** — guide to writing your own `LogSink`, - with worked examples (counting sink, file sink, hierarchical sink, tee, marker - filter, JSON-line sink). Read this if you're adding a backend. -- **[`STRUCTURED_LOGGING.md`](STRUCTURED_LOGGING.md)** — how SLF4J 2.x structured - logging (key/value pairs, fluent builder) maps onto `lib_logging`, including a - sketch of the future structured-event data model and a `JsonLineLogSink`. -- **[`LAZY_LOGGING.md`](LAZY_LOGGING.md)** — exploration of Kotlin `kotlin-logging` - style lambda emission (`logger.info { "expensive ${x}" }`), what other languages - do, and three options for adding it to `lib_logging`. Includes an industry survey. - -### "What about a real production backend?" - -- **[`LOGBACK_INTEGRATION.md`](LOGBACK_INTEGRATION.md)** — sketch of the future - `lib_logging_logback` module: appenders, layouts, filters, per-logger thresholds, - programmatic and file-based configuration, hot reload, async appenders, and a - migration table for SLF4J + Logback users. -- **[`NATIVE_BRIDGE.md`](NATIVE_BRIDGE.md)** — could we instead plug *real Java - Logback* in via the JIT bridge? Investigation, evidence, trade-offs, and a - recommendation. Short answer: feasible but not the primary path. - -### "Two libraries — what's the comparison?" - -- **[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — the design - comparison between `lib_logging` (SLF4J-shaped) and `lib_slogging` (slog-shaped). - Per-axis table, deep dives on markers and on `MDC` vs `With`, side-by-side code - for the common patterns, Ecstasy-idiom fit notes, the **eleven explicit reviewer - questions** we want feedback on, and a tentative recommendation. **Read this - first** if you are reviewing the project. -- **[`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md)** — explains why API choice is - the entry point to GCP / AWS / Azure observability ecosystems (drop-in adapters, - schema conventions, dashboards-that-already-work). Written for readers whose - deployment intuition is "ship a CLI installer" and need a tour of cloud-native - logging conventions. - -### "Could the design have looked different?" - -- **[`ALTERNATIVE_DESIGN_SLOG_STYLE.md`](ALTERNATIVE_DESIGN_SLOG_STYLE.md)** — - exploratory predecessor to `lib_slogging`: what an SLF4J-shaped library would look - like if modeled on Go's `log/slog` instead. Now superseded by the actual - `lib_slogging` module + `LIB_LOGGING_VS_LIB_SLOGGING.md` comparison; kept for - history. - -### "What's still uncertain?" - -- **[`RUNTIME_IMPLEMENTATION_PLAN.md`](RUNTIME_IMPLEMENTATION_PLAN.md)** — the - actionable plan for turning the v0 stub into a demo-worthy end-to-end round - trip. Stages, ordering, time estimates, definition of "demo worthy". -- **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** — running list of unresolved design - and implementation questions, with the trade-offs and a tentative recommendation - for each. None block the v0 stub; they block "v0 working end-to-end". +This branch (`lagergren/logging`) adds **two parallel logging libraries** to the +XDK. They are an experiment, not a final shipping decision: we built both so +reviewers can compare them side-by-side and tell us which API shape Ecstasy +should adopt long-term. + +- **[`lib_logging`](../../lib_logging/)** — modelled on **SLF4J 2.x + Logback**. + Familiar to anyone with JVM background. 51 unit tests, end-to-end demo. +- **[`lib_slogging`](../../lib_slogging/)** — modelled on **Go's `log/slog`** + (the modern cloud-native idiom). 22 unit tests, parallel design. + +If you only have time to read one document, read +**[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — it is +the comparison, the reviewer-question list, and the recommendation in one +place. + +## What we want from you + +There are **eleven explicit reviewer questions** in +[`LIB_LOGGING_VS_LIB_SLOGGING.md` § 5](LIB_LOGGING_VS_LIB_SLOGGING.md#5-what-we-want-reviewer-feedback-on) +and **seven language-design questions** (Q-D1..Q-D7) in +[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md). Both are calibrated for someone who +has skimmed the headline comparison; you do not need to read the entire doc +tree to weigh in. + +## Reading paths + +Pick the one that matches what you are. + +### Reviewing the proposal — what should I read? + +1. **[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — the + side-by-side comparison, deep dive on markers, eleven reviewer questions, + tentative recommendation. +2. **[`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md)** — why the API choice is + the entry point to GCP / AWS / Azure observability ecosystems. Explains the + modern deployment world for readers whose intuition is "ship a CLI + installer." +3. **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** — questions for the XTC + language designers (Q-D1..Q-D7), tracking list of work not yet implemented, + implementation tiers. + +That is enough to weigh in on the design choice. The rest of the tree is +support material. + +### I'm an SLF4J / Logback Java engineer — show me what changes + +1. **[`ECSTASY_VS_JAVA_EXAMPLES.md`](ECSTASY_VS_JAVA_EXAMPLES.md)** — every + SLF4J idiom with the equivalent Ecstasy code beside it. +2. **[`SLF4J_PARITY.md`](SLF4J_PARITY.md)** — exhaustive type-by-type and + method-by-method mapping. Reference, not narrative. +3. **[`INJECTED_LOGGER_EXAMPLE.md`](INJECTED_LOGGER_EXAMPLE.md)** — a complete + end-to-end example of `@Inject Logger logger;` with a runnable companion at + `manualTests/src/main/x/TestLogger.x`. + +### I'm a Go / slog engineer — show me what changes + +1. **[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — has + side-by-side slog → Ecstasy code throughout. +2. **The `lib_slogging` source** at + [`lib_slogging/src/main/x/slogging/`](../../lib_slogging/src/main/x/slogging/) + — the API surface is small (Logger, Handler, Record, Attr, Level) and each + file has a doc-comment naming its slog counterpart. + +### I'm an XTC language designer — what's open? + +1. **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** §§ "Questions for the XTC + language / runtime designers" (Q-D1..Q-D7). Each question is tied to a + concrete piece of code we wrote and includes the workaround we adopted. +2. **[`DESIGN.md`](DESIGN.md)** — `lib_logging` architecture, including the + `const` vs `service` rule for sinks and the per-fiber `MDC` mechanism. + +### I want to write my own sink / handler + +1. **[`CUSTOM_SINKS.md`](CUSTOM_SINKS.md)** — guide to writing a custom + `LogSink`, with worked examples (counting, file, hierarchical, tee, + marker-filtering, JSON-line). Includes the `const` vs `service` decision. +2. **[`STRUCTURED_LOGGING.md`](STRUCTURED_LOGGING.md)** — how SLF4J 2.x + structured logging maps onto `lib_logging`, with a sketch of a JSON sink. + +### I want to deploy this to GCP / AWS / Azure + +1. **[`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md)** — adapter graphs for the + three major clouds, with concrete examples and the table-stakes API + features each cloud product depends on. +2. **[`LOGBACK_INTEGRATION.md`](LOGBACK_INTEGRATION.md)** — sketch of a future + configuration-driven binding (Tier 3 work, not in this PR). +3. **[`NATIVE_BRIDGE.md`](NATIVE_BRIDGE.md)** — could we instead plug *real + Java Logback* in via the JIT bridge? Investigation, evidence, recommendation. + +### I want to migrate existing Ecstasy code that already does ad-hoc logging + +1. **[`PLATFORM_AND_EXAMPLES_ADAPTATION.md`](PLATFORM_AND_EXAMPLES_ADAPTATION.md)** + — surveys `~/src/platform` and `~/src/examples` and shows how their + `console.print($"... Info :")` patterns would migrate. + +## Full doc index + +| Doc | One-line description | +|---|---| +| **Top-level** | | +| [`README.md`](README.md) | This file. The "start here" entry point. | +| [`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md) | Headline comparison + reviewer questions. **Read first.** | +| [`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md) | Why the API choice is the entry point to cloud observability ecosystems. | +| [`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md) | Live tracking: open questions, designer questions (Q-D1..Q-D7), W-item parity list, implementation tiers. | +| **Design (`lib_logging` side)** | | +| [`DESIGN.md`](DESIGN.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | +| [`WHY_SLF4J_AND_INJECTION.md`](WHY_SLF4J_AND_INJECTION.md) | The original rationale for the SLF4J shape and injection-first acquisition. | +| [`XDK_ALIGNMENT.md`](XDK_ALIGNMENT.md) | Alignment with the conventions of other XDK libraries (`lib_ecstasy`, `lib_cli`, etc.). | +| **Usage / examples** | | +| [`INJECTED_LOGGER_EXAMPLE.md`](INJECTED_LOGGER_EXAMPLE.md) | End-to-end working example of `@Inject Logger`. | +| [`ECSTASY_VS_JAVA_EXAMPLES.md`](ECSTASY_VS_JAVA_EXAMPLES.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | +| [`SLF4J_PARITY.md`](SLF4J_PARITY.md) | Exhaustive type/method mapping reference. | +| [`PLATFORM_AND_EXAMPLES_ADAPTATION.md`](PLATFORM_AND_EXAMPLES_ADAPTATION.md) | Migration survey for existing Ecstasy code bases. | +| **Extension** | | +| [`CUSTOM_SINKS.md`](CUSTOM_SINKS.md) | How to write a custom `LogSink`, with worked examples. | +| [`STRUCTURED_LOGGING.md`](STRUCTURED_LOGGING.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | +| **Tier 3 / future work** | | +| [`LOGBACK_INTEGRATION.md`](LOGBACK_INTEGRATION.md) | Sketch of a configuration-driven Logback-style binding. | +| [`NATIVE_BRIDGE.md`](NATIVE_BRIDGE.md) | Wrapping real Java Logback through the JIT bridge — feasibility analysis. | +| [`LAZY_LOGGING.md`](LAZY_LOGGING.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | +| [`RUNTIME_IMPLEMENTATION_PLAN.md`](RUNTIME_IMPLEMENTATION_PLAN.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | +| **Archive** | | +| [`archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md`](archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md) | Predecessor to `lib_slogging`. Superseded by the actual module + the comparison doc. | +| [`archive/PLAN.md`](archive/PLAN.md) | Original master plan. Steps 1–8 landed; superseded by this README + `OPEN_QUESTIONS.md`. | ## Where the actual code lives -In the [`lib_logging/`](../../lib_logging) directory at the repo root. - ``` -lib_logging/ -├── README.md short module-level intro, links back here -├── build.gradle.kts standard XTC plugin + xunit-engine for tests +lib_logging/ SLF4J-shaped library +├── build.gradle.kts +├── README.md module-level intro └── src/ ├── main/x/ - │ ├── logging.x module declaration + │ ├── logging.x module declaration │ └── logging/ - │ ├── Level.x severity enum - │ ├── Marker.x marker interface (org.slf4j.Marker) - │ ├── BasicMarker.x default marker impl - │ ├── MarkerFactory.x factory service (org.slf4j.MarkerFactory) - │ ├── MDC.x mapped diagnostic context - │ ├── LogEvent.x immutable event const - │ ├── LogSink.x SPI — the API/impl boundary - │ ├── Logger.x user-facing facade (org.slf4j.Logger) + │ ├── Level.x severity enum + │ ├── Marker.x marker interface (org.slf4j.Marker) + │ ├── BasicMarker.x default marker impl + │ ├── MarkerFactory.x factory service + │ ├── MDC.x per-fiber mapped diagnostic context (const) + │ ├── LogEvent.x immutable event const (Marker[] markers) + │ ├── LogSink.x SPI — the API/impl boundary + │ ├── Logger.x user-facing facade interface │ ├── LoggingEventBuilder.x SLF4J 2.x fluent builder - │ ├── LoggerFactory.x non-injection acquisition path - │ ├── MessageFormatter.x {}-substitution + throwable promotion - │ ├── BasicLogger.x canonical Logger implementation - │ ├── BasicEventBuilder.x canonical builder implementation - │ ├── ConsoleLogSink.x default sink — writes through @Inject Console - │ ├── NoopLogSink.x drops every event - │ └── MemoryLogSink.x captures events in memory (test helper) - └── test/x/ - └── LoggingTest/ - ├── ListLogSink.x linked-list-backed test sink - ├── LevelTest.x - ├── EmissionTest.x end-to-end per-level routing - ├── LevelCheckTest.x threshold / *Enabled property semantics - ├── MarkerTest.x marker containment + propagation - ├── FluentBuilderTest.x atInfo()/atError() builder tests - └── CountingSinkTest.x shows how to plug in a custom sink + │ ├── LoggerFactory.x non-injection acquisition path + │ ├── LoggerRegistry.x identity-stable interning of named children + │ ├── MessageFormatter.x {}-substitution + throwable promotion + │ ├── BasicLogger.x canonical Logger impl (const) + │ ├── BasicEventBuilder.x canonical builder impl + │ ├── ConsoleLogSink.x default sink (const), forwards to @Inject Console + │ ├── NoopLogSink.x drops every event (const) + │ └── MemoryLogSink.x test-helper, captures events (service) + └── test/x/LoggingTest/ 51 unit tests + +lib_slogging/ slog-shaped sibling library +├── build.gradle.kts +└── src/ + ├── main/x/ + │ ├── slogging.x module declaration + │ └── slogging/ + │ ├── Level.x open Int severity (Level(severity, label)) + │ ├── Attr.x the single structured-data carrier + │ ├── Record.x immutable event const (the LogEvent equivalent) + │ ├── Handler.x SPI (the LogSink equivalent) + │ ├── Logger.x concrete user-facing const + │ ├── TextHandler.x default human-readable handler (const) + │ ├── JSONHandler.x JSON-Lines structured handler (const, skeleton) + │ ├── NopHandler.x drops every record (const) + │ └── MemoryHandler.x test-helper (service) + └── test/x/SLoggingTest/ 22 unit tests ``` -Every `.x` source file has a header comment naming what it corresponds to in SLF4J, -Logback, Log4j 2, or JUL — so an SLF4J reader knows exactly which Java type each -Ecstasy type plays the role of. - -## How to read this doc tree depending on who you are - -| You are... | Start with | Then read | -|---|---|---| -| New to the project, want the gist | `PLAN.md` | `WHY_SLF4J_AND_INJECTION.md`, `DESIGN.md` | -| An SLF4J/Logback Java engineer | `ECSTASY_VS_JAVA_EXAMPLES.md` | `SLF4J_PARITY.md`, `LOGBACK_INTEGRATION.md` | -| A Go/slog engineer | `ALTERNATIVE_DESIGN_SLOG_STYLE.md` | `WHY_SLF4J_AND_INJECTION.md`, `STRUCTURED_LOGGING.md` | -| Adding a backend / sink | `CUSTOM_SINKS.md` | `STRUCTURED_LOGGING.md`, `LOGBACK_INTEGRATION.md` | -| Investigating performance / hot paths | `LAZY_LOGGING.md` | `OPEN_QUESTIONS.md` (entries on async/copy) | -| Reviewing the proposal | `WHY_SLF4J_AND_INJECTION.md` | `OPEN_QUESTIONS.md`, `DESIGN.md` | -| Implementing the next chunk | `OPEN_QUESTIONS.md` | `PLAN.md` (ordering of work), `NATIVE_BRIDGE.md` | - ## Status -`lib_logging` is wired into the XDK build and ships its module in the distribution. The -public API surface and default impls compile (modulo Ecstasy syntax review — see -`OPEN_QUESTIONS.md`). The runtime side that resolves `@Inject Logger` to a real -`BasicLogger` instance is **not yet wired up**; that is the next step (entry 9 in -`OPEN_QUESTIONS.md`). - -The unit tests under `lib_logging/src/test/x/LoggingTest/` exercise the API by -constructing `BasicLogger` instances directly against an in-memory `ListLogSink`. They -do not depend on the runtime injection plumbing. +**Both libraries compile and pass tests.** End-to-end demo runs in +`manualTests/TestLogger.x` (interpreter side; JIT-side wiring is Tier 3). +Tier 1+2 work landed in this branch; Tier 3 (compiler default-name, +`AsyncLogSink`, `lib_logging_logback`, native bridge) is explicitly out of +scope for this PR — see `OPEN_QUESTIONS.md` for the tier breakdown. The +branch is ready for reviewer feedback on the design choice. ## Reference -The original SLF4J provider/architecture writeup that shaped much of this design is in -[`slf4j_full_guide.md`](../../slf4j_full_guide.md) at the repo root. It is a Java-side -guide to building a custom SLF4J binding and is the source of the architectural -template (`MyLangLogger` → `RuntimeLogSink` → backend) we adapted into -`Logger` → `LogSink` → backend. +The original SLF4J architecture writeup that shaped much of `lib_logging` is in +[`slf4j_full_guide.md`](../../slf4j_full_guide.md) at the repo root. It is the +source of the architectural template (`MyLangLogger` → `RuntimeLogSink` → +backend) we adapted into `Logger` → `LogSink` → backend. diff --git a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md b/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md index b5b7273080..c3a8de2acc 100644 --- a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md +++ b/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md @@ -383,3 +383,8 @@ If you want this in three lines: `"default"`. After that, the platform-repo migration PR is the demo. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/SLF4J_PARITY.md b/doc/logging/SLF4J_PARITY.md index a90add2c08..4c4c29d7e5 100644 --- a/doc/logging/SLF4J_PARITY.md +++ b/doc/logging/SLF4J_PARITY.md @@ -119,3 +119,8 @@ SLF4J's `{}` placeholder semantics are reproduced verbatim by `MessageFormatter. `OPEN_QUESTIONS.md` for the open piece around per-container overrides. - `Off` level, useful for sink configuration. SLF4J expresses this through level checks in `Logger.isEnabledFor(...)`. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/STRUCTURED_LOGGING.md b/doc/logging/STRUCTURED_LOGGING.md index f8eb63680c..075b66f1a9 100644 --- a/doc/logging/STRUCTURED_LOGGING.md +++ b/doc/logging/STRUCTURED_LOGGING.md @@ -215,3 +215,8 @@ For v0.1, the right order is: Everything beyond that — typed value formatting, schema validation, OpenTelemetry integration, log-correlation IDs propagated across services — is sink-side. The API is already shaped to support it without changes. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/WHY_SLF4J_AND_INJECTION.md b/doc/logging/WHY_SLF4J_AND_INJECTION.md index 41b91a4570..60182a7d0f 100644 --- a/doc/logging/WHY_SLF4J_AND_INJECTION.md +++ b/doc/logging/WHY_SLF4J_AND_INJECTION.md @@ -321,3 +321,8 @@ it because: per-container injection at the language level. Ecstasy does. We should use it. This is what "instantly familiar to all SLF4J users *and* better" looks like. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/XDK_ALIGNMENT.md b/doc/logging/XDK_ALIGNMENT.md index 5aaf5aa2bd..475fc6fab9 100644 --- a/doc/logging/XDK_ALIGNMENT.md +++ b/doc/logging/XDK_ALIGNMENT.md @@ -200,3 +200,8 @@ we do things." The only thing missing for full first-class status is the runtime-side resource registration (`@Inject Logger` actually resolving), which is the next chunk of work described in `OPEN_QUESTIONS.md` items 1, 2, and 9. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md b/doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md similarity index 97% rename from doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md rename to doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md index 524eadccbd..fb6a88bc38 100644 --- a/doc/logging/ALTERNATIVE_DESIGN_SLOG_STYLE.md +++ b/doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md @@ -1,5 +1,10 @@ # Alternative design — what `lib_logging` would look like as an `slog`-style library +> **Archived (2026-05).** This was the predecessor to the actual `lib_slogging` +> module that now lives at the repo root. The headline comparison is in +> `../LIB_LOGGING_VS_LIB_SLOGGING.md`. Kept for history; no current decisions +> hang on the contents below. + This is an **exploratory** document. It exists so we have a clear-eyed picture of what we'd be giving up (and what we'd be gaining) if we modeled the Ecstasy logging library on Go's `log/slog` instead of SLF4J. No skeleton code is being added to the repo for diff --git a/doc/logging/PLAN.md b/doc/logging/archive/PLAN.md similarity index 93% rename from doc/logging/PLAN.md rename to doc/logging/archive/PLAN.md index 431040c656..036026a7b1 100644 --- a/doc/logging/PLAN.md +++ b/doc/logging/archive/PLAN.md @@ -1,5 +1,12 @@ # lib_logging — Plan +> **Archived (2026-05).** This was the original master plan written when the +> branch was still a stub. Steps 1–8 have all landed; step 9 (compiler default +> name) and step 10 (`lib_logging_logback`) are tracked as Tier 3 in +> `../OPEN_QUESTIONS.md`. The current state is best understood from +> `../README.md` (entry point) and `../OPEN_QUESTIONS.md` (live tracking). +> Kept for history; no live decisions hang on the contents below. + This is the master plan for the experimental `lib_logging` module on the `lagergren/logging` branch. It captures scope, non-goals, ordering of work, and the explicit decisions the design is built on. From dfbdce0478c0117b40cef2a15ec6bf2db4a6bf0f Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Fri, 1 May 2026 22:31:45 +0200 Subject: [PATCH 07/28] doc/logging: kebab-case doc names + remove archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cleanup passes on the doc tree: 1. Renamed every SCREAMING_SNAKE_CASE.md file to lower-case-kebab.md. README.md kept in caps (the conventional one exception). 16 docs renamed; every reference (markdown links + .x source comments + .java + .kts) swept and updated. Verified zero broken links across the tree. 2. Deleted the archive/ subdir. The two paragraphs worth keeping were folded into live docs: - The "hybrid SLF4J-facade with slog event model" alternative noted in archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md is now a paragraph in lib-logging-vs-lib-slogging.md § "Tentative recommendation." - The explicit "Non-goals (v0)" list from archive/PLAN.md is now a section in design.md. Everything else in those archive files was either already captured in live docs (goals → README + comparison; ordering of work → tier table; slog API sketches → the actual lib_slogging module) or had been superseded by the comparison doc. The doc tree is now 17 active markdown files, all kebab-cased, all linked from a kebab-case index in README.md. No archive subdir. --- doc/logging/README.md | 73 ++-- .../archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md | 316 ------------------ doc/logging/archive/PLAN.md | 130 ------- ...UD_INTEGRATION.md => cloud-integration.md} | 4 +- .../{CUSTOM_SINKS.md => custom-sinks.md} | 6 +- doc/logging/{DESIGN.md => design.md} | 41 ++- ...XAMPLES.md => ecstasy-vs-java-examples.md} | 4 +- ..._EXAMPLE.md => injected-logger-example.md} | 2 +- .../{LAZY_LOGGING.md => lazy-logging.md} | 2 +- ...GING.md => lib-logging-vs-lib-slogging.md} | 26 +- ..._INTEGRATION.md => logback-integration.md} | 4 +- .../{NATIVE_BRIDGE.md => native-bridge.md} | 4 +- .../{OPEN_QUESTIONS.md => open-questions.md} | 32 +- ...md => platform-and-examples-adaptation.md} | 0 ...PLAN.md => runtime-implementation-plan.md} | 28 +- .../{SLF4J_PARITY.md => slf4j-parity.md} | 4 +- ...TURED_LOGGING.md => structured-logging.md} | 4 +- ...NJECTION.md => why-slf4j-and-injection.md} | 0 .../{XDK_ALIGNMENT.md => xdk-alignment.md} | 2 +- lib_logging/README.md | 14 +- lib_logging/src/main/x/logging.x | 2 +- lib_logging/src/main/x/logging/BasicLogger.x | 2 +- .../src/main/x/logging/ConsoleLogSink.x | 4 +- lib_logging/src/main/x/logging/LogSink.x | 6 +- lib_logging/src/main/x/logging/Marker.x | 2 +- .../src/main/x/logging/MemoryLogSink.x | 2 +- lib_logging/src/main/x/logging/NoopLogSink.x | 2 +- .../src/test/x/LoggingTest/CountingSink.x | 2 +- .../src/test/x/LoggingTest/ListLogSink.x | 2 +- lib_slogging/src/main/x/slogging.x | 6 +- lib_slogging/src/main/x/slogging/Attr.x | 2 +- lib_slogging/src/main/x/slogging/Handler.x | 2 +- lib_slogging/src/main/x/slogging/Logger.x | 2 +- .../src/main/x/slogging/MemoryHandler.x | 2 +- lib_slogging/src/main/x/slogging/NopHandler.x | 2 +- .../src/main/x/slogging/TextHandler.x | 4 +- .../src/test/x/SLoggingTest/ListHandler.x | 2 +- manualTests/src/main/x/TestLogger.x | 2 +- 38 files changed, 165 insertions(+), 579 deletions(-) delete mode 100644 doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md delete mode 100644 doc/logging/archive/PLAN.md rename doc/logging/{CLOUD_INTEGRATION.md => cloud-integration.md} (98%) rename doc/logging/{CUSTOM_SINKS.md => custom-sinks.md} (97%) rename doc/logging/{DESIGN.md => design.md} (86%) rename doc/logging/{ECSTASY_VS_JAVA_EXAMPLES.md => ecstasy-vs-java-examples.md} (97%) rename doc/logging/{INJECTED_LOGGER_EXAMPLE.md => injected-logger-example.md} (99%) rename doc/logging/{LAZY_LOGGING.md => lazy-logging.md} (99%) rename doc/logging/{LIB_LOGGING_VS_LIB_SLOGGING.md => lib-logging-vs-lib-slogging.md} (96%) rename doc/logging/{LOGBACK_INTEGRATION.md => logback-integration.md} (99%) rename doc/logging/{NATIVE_BRIDGE.md => native-bridge.md} (98%) rename doc/logging/{OPEN_QUESTIONS.md => open-questions.md} (93%) rename doc/logging/{PLATFORM_AND_EXAMPLES_ADAPTATION.md => platform-and-examples-adaptation.md} (100%) rename doc/logging/{RUNTIME_IMPLEMENTATION_PLAN.md => runtime-implementation-plan.md} (94%) rename doc/logging/{SLF4J_PARITY.md => slf4j-parity.md} (97%) rename doc/logging/{STRUCTURED_LOGGING.md => structured-logging.md} (98%) rename doc/logging/{WHY_SLF4J_AND_INJECTION.md => why-slf4j-and-injection.md} (100%) rename doc/logging/{XDK_ALIGNMENT.md => xdk-alignment.md} (99%) diff --git a/doc/logging/README.md b/doc/logging/README.md index 0c4e539745..c5dba9d4f1 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -11,16 +11,16 @@ should adopt long-term. (the modern cloud-native idiom). 22 unit tests, parallel design. If you only have time to read one document, read -**[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — it is +**[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — it is the comparison, the reviewer-question list, and the recommendation in one place. ## What we want from you There are **eleven explicit reviewer questions** in -[`LIB_LOGGING_VS_LIB_SLOGGING.md` § 5](LIB_LOGGING_VS_LIB_SLOGGING.md#5-what-we-want-reviewer-feedback-on) +[`lib-logging-vs-lib-slogging.md` § 5](lib-logging-vs-lib-slogging.md#5-what-we-want-reviewer-feedback-on) and **seven language-design questions** (Q-D1..Q-D7) in -[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md). Both are calibrated for someone who +[`open-questions.md`](open-questions.md). Both are calibrated for someone who has skimmed the headline comparison; you do not need to read the entire doc tree to weigh in. @@ -30,14 +30,14 @@ Pick the one that matches what you are. ### Reviewing the proposal — what should I read? -1. **[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — the +1. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — the side-by-side comparison, deep dive on markers, eleven reviewer questions, tentative recommendation. -2. **[`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md)** — why the API choice is +2. **[`cloud-integration.md`](cloud-integration.md)** — why the API choice is the entry point to GCP / AWS / Azure observability ecosystems. Explains the modern deployment world for readers whose intuition is "ship a CLI installer." -3. **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** — questions for the XTC +3. **[`open-questions.md`](open-questions.md)** — questions for the XTC language designers (Q-D1..Q-D7), tracking list of work not yet implemented, implementation tiers. @@ -46,17 +46,17 @@ support material. ### I'm an SLF4J / Logback Java engineer — show me what changes -1. **[`ECSTASY_VS_JAVA_EXAMPLES.md`](ECSTASY_VS_JAVA_EXAMPLES.md)** — every +1. **[`ecstasy-vs-java-examples.md`](ecstasy-vs-java-examples.md)** — every SLF4J idiom with the equivalent Ecstasy code beside it. -2. **[`SLF4J_PARITY.md`](SLF4J_PARITY.md)** — exhaustive type-by-type and +2. **[`slf4j-parity.md`](slf4j-parity.md)** — exhaustive type-by-type and method-by-method mapping. Reference, not narrative. -3. **[`INJECTED_LOGGER_EXAMPLE.md`](INJECTED_LOGGER_EXAMPLE.md)** — a complete +3. **[`injected-logger-example.md`](injected-logger-example.md)** — a complete end-to-end example of `@Inject Logger logger;` with a runnable companion at `manualTests/src/main/x/TestLogger.x`. ### I'm a Go / slog engineer — show me what changes -1. **[`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md)** — has +1. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — has side-by-side slog → Ecstasy code throughout. 2. **The `lib_slogging` source** at [`lib_slogging/src/main/x/slogging/`](../../lib_slogging/src/main/x/slogging/) @@ -65,33 +65,33 @@ support material. ### I'm an XTC language designer — what's open? -1. **[`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md)** §§ "Questions for the XTC +1. **[`open-questions.md`](open-questions.md)** §§ "Questions for the XTC language / runtime designers" (Q-D1..Q-D7). Each question is tied to a concrete piece of code we wrote and includes the workaround we adopted. -2. **[`DESIGN.md`](DESIGN.md)** — `lib_logging` architecture, including the +2. **[`design.md`](design.md)** — `lib_logging` architecture, including the `const` vs `service` rule for sinks and the per-fiber `MDC` mechanism. ### I want to write my own sink / handler -1. **[`CUSTOM_SINKS.md`](CUSTOM_SINKS.md)** — guide to writing a custom +1. **[`custom-sinks.md`](custom-sinks.md)** — guide to writing a custom `LogSink`, with worked examples (counting, file, hierarchical, tee, marker-filtering, JSON-line). Includes the `const` vs `service` decision. -2. **[`STRUCTURED_LOGGING.md`](STRUCTURED_LOGGING.md)** — how SLF4J 2.x +2. **[`structured-logging.md`](structured-logging.md)** — how SLF4J 2.x structured logging maps onto `lib_logging`, with a sketch of a JSON sink. ### I want to deploy this to GCP / AWS / Azure -1. **[`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md)** — adapter graphs for the +1. **[`cloud-integration.md`](cloud-integration.md)** — adapter graphs for the three major clouds, with concrete examples and the table-stakes API features each cloud product depends on. -2. **[`LOGBACK_INTEGRATION.md`](LOGBACK_INTEGRATION.md)** — sketch of a future +2. **[`logback-integration.md`](logback-integration.md)** — sketch of a future configuration-driven binding (Tier 3 work, not in this PR). -3. **[`NATIVE_BRIDGE.md`](NATIVE_BRIDGE.md)** — could we instead plug *real +3. **[`native-bridge.md`](native-bridge.md)** — could we instead plug *real Java Logback* in via the JIT bridge? Investigation, evidence, recommendation. ### I want to migrate existing Ecstasy code that already does ad-hoc logging -1. **[`PLATFORM_AND_EXAMPLES_ADAPTATION.md`](PLATFORM_AND_EXAMPLES_ADAPTATION.md)** +1. **[`platform-and-examples-adaptation.md`](platform-and-examples-adaptation.md)** — surveys `~/src/platform` and `~/src/examples` and shows how their `console.print($"... Info :")` patterns would migrate. @@ -101,29 +101,26 @@ support material. |---|---| | **Top-level** | | | [`README.md`](README.md) | This file. The "start here" entry point. | -| [`LIB_LOGGING_VS_LIB_SLOGGING.md`](LIB_LOGGING_VS_LIB_SLOGGING.md) | Headline comparison + reviewer questions. **Read first.** | -| [`CLOUD_INTEGRATION.md`](CLOUD_INTEGRATION.md) | Why the API choice is the entry point to cloud observability ecosystems. | -| [`OPEN_QUESTIONS.md`](OPEN_QUESTIONS.md) | Live tracking: open questions, designer questions (Q-D1..Q-D7), W-item parity list, implementation tiers. | +| [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | Headline comparison + reviewer questions. **Read first.** | +| [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | +| [`open-questions.md`](open-questions.md) | Live tracking: open questions, designer questions (Q-D1..Q-D7), W-item parity list, implementation tiers. | | **Design (`lib_logging` side)** | | -| [`DESIGN.md`](DESIGN.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | -| [`WHY_SLF4J_AND_INJECTION.md`](WHY_SLF4J_AND_INJECTION.md) | The original rationale for the SLF4J shape and injection-first acquisition. | -| [`XDK_ALIGNMENT.md`](XDK_ALIGNMENT.md) | Alignment with the conventions of other XDK libraries (`lib_ecstasy`, `lib_cli`, etc.). | +| [`design.md`](design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | +| [`why-slf4j-and-injection.md`](why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | +| [`xdk-alignment.md`](xdk-alignment.md) | Alignment with the conventions of other XDK libraries (`lib_ecstasy`, `lib_cli`, etc.). | | **Usage / examples** | | -| [`INJECTED_LOGGER_EXAMPLE.md`](INJECTED_LOGGER_EXAMPLE.md) | End-to-end working example of `@Inject Logger`. | -| [`ECSTASY_VS_JAVA_EXAMPLES.md`](ECSTASY_VS_JAVA_EXAMPLES.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | -| [`SLF4J_PARITY.md`](SLF4J_PARITY.md) | Exhaustive type/method mapping reference. | -| [`PLATFORM_AND_EXAMPLES_ADAPTATION.md`](PLATFORM_AND_EXAMPLES_ADAPTATION.md) | Migration survey for existing Ecstasy code bases. | +| [`injected-logger-example.md`](injected-logger-example.md) | End-to-end working example of `@Inject Logger`. | +| [`ecstasy-vs-java-examples.md`](ecstasy-vs-java-examples.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | +| [`slf4j-parity.md`](slf4j-parity.md) | Exhaustive type/method mapping reference. | +| [`platform-and-examples-adaptation.md`](platform-and-examples-adaptation.md) | Migration survey for existing Ecstasy code bases. | | **Extension** | | -| [`CUSTOM_SINKS.md`](CUSTOM_SINKS.md) | How to write a custom `LogSink`, with worked examples. | -| [`STRUCTURED_LOGGING.md`](STRUCTURED_LOGGING.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | +| [`custom-sinks.md`](custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | +| [`structured-logging.md`](structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | | **Tier 3 / future work** | | -| [`LOGBACK_INTEGRATION.md`](LOGBACK_INTEGRATION.md) | Sketch of a configuration-driven Logback-style binding. | -| [`NATIVE_BRIDGE.md`](NATIVE_BRIDGE.md) | Wrapping real Java Logback through the JIT bridge — feasibility analysis. | -| [`LAZY_LOGGING.md`](LAZY_LOGGING.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | -| [`RUNTIME_IMPLEMENTATION_PLAN.md`](RUNTIME_IMPLEMENTATION_PLAN.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | -| **Archive** | | -| [`archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md`](archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md) | Predecessor to `lib_slogging`. Superseded by the actual module + the comparison doc. | -| [`archive/PLAN.md`](archive/PLAN.md) | Original master plan. Steps 1–8 landed; superseded by this README + `OPEN_QUESTIONS.md`. | +| [`logback-integration.md`](logback-integration.md) | Sketch of a configuration-driven Logback-style binding. | +| [`native-bridge.md`](native-bridge.md) | Wrapping real Java Logback through the JIT bridge — feasibility analysis. | +| [`lazy-logging.md`](lazy-logging.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | +| [`runtime-implementation-plan.md`](runtime-implementation-plan.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | ## Where the actual code lives @@ -178,7 +175,7 @@ lib_slogging/ slog-shaped sibling library `manualTests/TestLogger.x` (interpreter side; JIT-side wiring is Tier 3). Tier 1+2 work landed in this branch; Tier 3 (compiler default-name, `AsyncLogSink`, `lib_logging_logback`, native bridge) is explicitly out of -scope for this PR — see `OPEN_QUESTIONS.md` for the tier breakdown. The +scope for this PR — see `open-questions.md` for the tier breakdown. The branch is ready for reviewer feedback on the design choice. ## Reference diff --git a/doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md b/doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md deleted file mode 100644 index fb6a88bc38..0000000000 --- a/doc/logging/archive/ALTERNATIVE_DESIGN_SLOG_STYLE.md +++ /dev/null @@ -1,316 +0,0 @@ -# Alternative design — what `lib_logging` would look like as an `slog`-style library - -> **Archived (2026-05).** This was the predecessor to the actual `lib_slogging` -> module that now lives at the repo root. The headline comparison is in -> `../LIB_LOGGING_VS_LIB_SLOGGING.md`. Kept for history; no current decisions -> hang on the contents below. - -This is an **exploratory** document. It exists so we have a clear-eyed picture of what -we'd be giving up (and what we'd be gaining) if we modeled the Ecstasy logging library -on Go's `log/slog` instead of SLF4J. No skeleton code is being added to the repo for -this design; everything below is illustrative. - -The file is organised symmetrically with the SLF4J design so you can A/B them. - -## What `slog` is - -`slog` is the structured-logging package added to Go's standard library in Go 1.21. Its -shape diverges from SLF4J in three significant ways: - -1. **Structured first, free-form text second.** `slog` calls take a message followed by - alternating key/value pairs as the *primary* mechanism, not as an addendum: - `slog.Info("processed", "count", 42, "duration", elapsed)`. -2. **`Handler` instead of `Appender`/`Encoder`.** A handler is a single combined - "filter + render + ship" object. The standard library ships `TextHandler` and - `JSONHandler`. Custom handlers wrap or replace them. -3. **`Logger` is a value type, not a service.** Loggers are cheap to derive: `child := - logger.With("requestId", id)` returns a new logger that prepends those attributes to - every event. There is no `MDC`-style ambient context — context is lexical. - -These three together produce a quite different developer experience. - -## What `lib_logging` would look like as an slog-style library - -### Core types - -The user-facing facade collapses from "Logger plus Marker plus MDC plus Builder" down to -"Logger plus Attr". A sketch: - -```ecstasy -/** - * The slog-style logger. Holds an immutable list of pre-bound attributes plus a handler. - */ -const Logger(Attr[] attrs, Handler handler) { - - @RO Boolean traceEnabled.get() = handler.enabled(Trace, attrs); - @RO Boolean debugEnabled.get() = handler.enabled(Debug, attrs); - // ... - - void info (String message, Attr[] extra = []) = log(Info, message, extra); - void debug(String message, Attr[] extra = []) = log(Debug, message, extra); - void warn (String message, Attr[] extra = []) = log(Warn, message, extra); - void error(String message, Attr[] extra = []) = log(Error, message, extra); - void trace(String message, Attr[] extra = []) = log(Trace, message, extra); - - /** - * Derive a new logger with extra attributes pre-bound. - */ - Logger with(Attr... extra) { - return new Logger(attrs + extra, handler); - } - - /** - * Derive a new logger inside a *group*; child attributes are nested under `name` - * in structured output. - */ - Logger group(String name) { - return new Logger(attrs.add(GroupOpen(name)), handler); - } - - void log(Level level, String message, Attr[] extra) { - if (!handler.enabled(level, attrs)) { - return; - } - handler.handle(new LogRecord(timestamp=clock.now, - level=level, - message=message, - attrs=attrs + extra)); - } -} -``` - -### The `Attr` type - -This is the unit of structured information. Always typed key/value, never free-form: - -```ecstasy -const Attr(String key, AttrValue value); - -const AttrValue - | StringValue(String value) - | IntValue(Int value) - | FloatValue(Float value) - | BoolValue(Boolean value) - | TimeValue(Time value) - | DurationValue(Duration value) - | ErrorValue(Exception value) - | GroupValue(Attr[] members) // nested object - | LazyValue(function AttrValue ()); // resolved at handler time -``` - -`LazyValue` is the slog `LogValuer` mechanism. The handler calls the function only when -the event is actually being emitted, so disabled levels never resolve the value. - -### The `Handler` interface - -Replaces `LogSink`. Takes a richer record, returns nothing: - -```ecstasy -interface Handler { - Boolean enabled(Level level, Attr[] context); - void handle(LogRecord record); - - /** - * Return a new handler that pre-binds these attributes; child loggers' `.with(...)` - * derivation goes through this so the handler can pre-render context once. - */ - Handler withAttrs(Attr[] extra); - - /** - * Open a structured group — output between matching `WithGroup` boundaries gets - * nested under `name` in the rendered output. - */ - Handler withGroup(String name); -} -``` - -### Acquisition - -`@Inject Logger logger` still works — the binding is the platform-controlled handler. -Derived loggers are explicit: - -```ecstasy -@Inject Logger logger; - -void handleRequest(Request req) { - Logger reqLog = logger.with( - new Attr("requestId", new StringValue(req.id)), - new Attr("user", new StringValue(req.user)) - ); - - reqLog.info("incoming"); - process(req, reqLog); -} - -void process(Request req, Logger log) { - log.info("validating", [new Attr("size", new IntValue(req.size))]); - // ... -} -``` - -There is no `MDC.put`. Context lives on a logger value that gets passed down explicitly. - -### Default handlers - -```ecstasy -service TextHandler implements Handler { /* writes "key=value key=value" lines */ } -service JsonHandler implements Handler { /* one JSON object per line */ } -service TeeHandler implements Handler { /* fans out to many sub-handlers */ } -``` - -`TextHandler` is the analogue of slog's `TextHandler`; `JsonHandler` is `slog.JSONHandler`. - -### Example call sites - -**Free-form log line, no structured fields:** - -```ecstasy -logger.info("startup complete"); -``` - -**Structured event:** - -```ecstasy -logger.info("payment processed", [ - new Attr("paymentId", new StringValue(payment.id)), - new Attr("amount", new IntValue(payment.amount)), - new Attr("currency", new StringValue(payment.currency)) -]); -``` - -**Nested group:** - -```ecstasy -logger.group("network").info("connected", [ - new Attr("host", new StringValue(addr.host)), - new Attr("port", new IntValue(addr.port)) -]); -``` - -Renders as JSON: - -```json -{"level":"INFO","msg":"connected","network":{"host":"db.internal","port":5432}} -``` - -**Lazy attribute:** - -```ecstasy -logger.debug("snapshot", [ - new Attr("dump", new LazyValue(() -> new StringValue(serializer.dump(thing)))) -]); -``` - -`serializer.dump` runs only if `Debug` is enabled. - -**Per-request derived logger (replaces MDC):** - -```ecstasy -@Inject Logger logger; -Logger reqLog = logger - .with(new Attr("requestId", new StringValue(req.id))) - .with(new Attr("tenant", new StringValue(req.tenant))); - -reqLog.info("dispatch"); -process(req, reqLog); -``` - -## What this design wins - -- **Structured logging is the primary path.** No KV pairs glued onto the side of a - free-form message; the message is one of many fields. JSON output is the natural - default. This is what modern observability stacks (Datadog, Loki, Grafana, ELK) want - anyway. -- **No ambient state.** No MDC `put`/`remove` ceremony. No surprise context bleed - between requests because someone forgot a `finally` block. Loggers are values. -- **`with` and `group` are honest about what they do.** A derived logger is a new value; - deriving is cheap (`O(1)` allocation) and obviously local. -- **Lazy values are first-class.** `LazyValue` slots into the same `AttrValue` union as - every other type; no separate Supplier-overload story. -- **Handler-side structure, not encoder-side.** A handler that wants pretty-print JSON - reads the structured `Attr[]` directly. There is no message-text-first / structure- - glued-on bifurcation. - -## What this design loses - -- **No instant familiarity for the SLF4J majority.** The dominant working population of - backend engineers in the world today is "people who write `log.info("...", arg)` in - Java." They will look at this and have to learn a new model. That cost is real. -- **No first-class `Marker`.** slog has no markers. Routing-by-tag is done either via - attributes (`new Attr("category", ...)`) plus a handler that filters on them, or via - multiple handlers. This is fine in principle but requires user code to learn the new - pattern. -- **No `MDC`.** Context propagation is *explicit*: derive a logger and pass it down. In - Ecstasy fibers this is sometimes great (no leakage) and sometimes painful (every - function in the chain needs a `Logger` parameter). Java/SLF4J users hit this and - immediately ask "where's MDC?" because that's the muscle memory. -- **More allocation per call site, on the surface.** Every structured event constructs - an `Attr[]` array literal. The compiler can almost certainly elide most of this, but - it's a different shape than the SLF4J `(message, args, ...)` flat call. -- **Verbosity for simple unstructured events.** `slog.Info("starting up")` is fine but - the moment you want one piece of context you're typing - `[new Attr("port", new IntValue(8080))]`. SLF4J's `info("starting up on {}", port)` is - shorter for the human reader, even if it's worse for machines. -- **Configuration story is less standardized.** SLF4J inherits Logback's - configuration-file ecosystem (XML, programmatic, autoscan, reload). slog has handlers, - and that's it. Anything fancier you build yourself. - -## Two-axis comparison - -| | SLF4J-shape (proposed) | slog-shape (this doc) | -|---|---|---| -| **Familiarity to Java engineers** | Instant | Requires learning a new model | -| **Familiarity to Go engineers** | Slight pivot | Native | -| **Structured-first vs message-first** | Message-first, KV pairs additive | Structured-first | -| **Context propagation** | MDC (ambient) + `Logger.with` (explicit, future) | `Logger.with` only (explicit) | -| **Markers** | First-class | Replaced by attribute-based filtering | -| **Lazy values** | Lambda overloads (Option 1 from `LAZY_LOGGING.md`) | `LazyValue` in the `AttrValue` union | -| **JSON output** | Sink-side encoder (`JsonLineLogSink`) | Native (`JsonHandler`) | -| **API↔impl boundary** | `LogSink` interface | `Handler` interface | -| **Configuration model** | Inherits Logback ecosystem (future) | DIY per-handler | -| **Allocation per disabled call** | Zero (level check, no formatting, no MDC snapshot) | Zero (level check first) | -| **Allocation per enabled simple call** | One `LogEvent` const | One `LogRecord` const + one `Attr[]` | -| **Default sink/handler shipped with v0** | `ConsoleLogSink` (text) | `TextHandler`, `JsonHandler` | - -Both designs land at the same place on performance once the level check short-circuits -disabled events. They diverge sharply on user-facing ergonomics. - -## What we'd be saying yes to, what we'd be saying no to - -**Yes:** - -- A clean slate that maps onto modern observability tools. -- One less concept (no MDC). -- Structured logging without the "but the encoder has to be configured" footnote. - -**No:** - -- Inheriting SLF4J's audience for free. -- Inheriting Logback's configuration ecosystem for free (later). -- Inheriting markers. - -## Why we're not picking this - -The recommendation in `WHY_SLF4J_AND_INJECTION.md` stands. The argument compresses to: -"the audience that matters most is SLF4J users, and SLF4J's design choices are battle- -tested wins, not legacy baggage." But the slog-shape is a coherent, internally -consistent alternative — it is not a strawman. If at some future point the centre of -gravity in backend engineering shifts decisively toward Go-shaped APIs, this document is -the starting point for revisiting the choice. We can also implement an `slog`-style -*adapter* over the SLF4J-shaped core later, exactly the way `slf4j-jul-impl` adapts JUL -to SLF4J — which is a reminder that the choice is recoverable in either direction. - -## Hybrid path, if we ever want it - -The interesting hybrid is **SLF4J-shaped facade, slog-shaped event model.** Concretely: - -- Keep `Logger`, `Marker`, `MDC`, the fluent builder. -- Replace the free-form `arguments: Object[]` on `LogEvent` with a typed - `attrs: Attr[]`. -- Have `MessageFormatter` resolve `{}` placeholders against `attrs` by index, but also - have a `JsonLineLogSink` render `attrs` directly as a JSON object. - -That gives SLF4J users their shape and slog users their wire format. It's also -strictly more complex than either pure design. We do not recommend it for v0; the right -moment to consider it is after the basic SLF4J-shaped library is shipping and we know -which axis we wish we had more of. diff --git a/doc/logging/archive/PLAN.md b/doc/logging/archive/PLAN.md deleted file mode 100644 index 036026a7b1..0000000000 --- a/doc/logging/archive/PLAN.md +++ /dev/null @@ -1,130 +0,0 @@ -# lib_logging — Plan - -> **Archived (2026-05).** This was the original master plan written when the -> branch was still a stub. Steps 1–8 have all landed; step 9 (compiler default -> name) and step 10 (`lib_logging_logback`) are tracked as Tier 3 in -> `../OPEN_QUESTIONS.md`. The current state is best understood from -> `../README.md` (entry point) and `../OPEN_QUESTIONS.md` (live tracking). -> Kept for history; no live decisions hang on the contents below. - -This is the master plan for the experimental `lib_logging` module on the -`lagergren/logging` branch. It captures scope, non-goals, ordering of work, and the -explicit decisions the design is built on. - -## Goals (in priority order) - -1. **Instant familiarity for SLF4J users.** Anyone who has used SLF4J 2.x in Java should - be able to read Ecstasy logging code and immediately know what it does. Same level set, - same `{}` parameter substitution semantics, same throwable promotion rule, same - marker / MDC concepts, same fluent event builder shape. - -2. **Simple to use.** `@Inject Logger logger;` should be all most users ever write to get - a working logger. No boilerplate factory calls, no static initializers, no compile-time - config files in the default path. - -3. **Same API/impl boundary as SLF4J.** `slf4j-api` is one jar; `slf4j-simple`, - `logback-classic`, `log4j2-slf4j` are the bindings. We want the same separability so the - default ships in the box but a richer backend can be swapped in without changing caller - code. The boundary is the `LogSink` interface. - -4. **Compatible with Ecstasy's injection model.** Loggers are obtained via - `@Inject Logger`, optionally with a name (`@Inject("foo") Logger`). The platform/runtime - controls which sink is wired up — same posture as `Console`, `Clock`, `Random`. - -5. **Minimum-viable surface includes the things people *expect* even if rarely used.** - Specifically: markers, MDC, fluent event builder. Default implementations may be - no-ops, but the types must exist so user code that uses them compiles and runs against - the default sink without modification. - -6. **Future-ready for a logback-style backend.** Configuration-driven appenders, layouts, - filters, hierarchical per-logger thresholds. Not implemented now, but designed-for so - it's a swap-in (`LogSink` impl) rather than an API rewrite. - -## Non-goals (for the v0 stub) - -- Distributed tracing context propagation. (MDC carries strings; that's it.) -- Async / batched / buffered sinks. The contract allows them; the default doesn't do them. -- Configuration file format. The default sink has one knob (`rootLevel`) and that's it. - A real config story belongs to the future logback-style backend module. -- Compile-time logger-name defaulting from the enclosing module. See `OPEN_QUESTIONS.md`. - -## Module layout - -``` -lib_logging/ - build.gradle.kts - src/main/x/ - logging.x module declaration - logging/ - Level.x - Marker.x - BasicMarker.x - MarkerFactory.x - MDC.x - LogEvent.x - LogSink.x - Logger.x - LoggingEventBuilder.x - LoggerFactory.x - MessageFormatter.x - BasicLogger.x - BasicEventBuilder.x - ConsoleLogSink.x - NoopLogSink.x - MemoryLogSink.x - docs/ - PLAN.md this file - DESIGN.md architecture - SLF4J_PARITY.md API mapping - ECSTASY_VS_JAVA_EXAMPLES.md side-by-side - CUSTOM_SINKS.md - LOGBACK_INTEGRATION.md - NATIVE_BRIDGE.md - OPEN_QUESTIONS.md -``` - -The boundary between API and implementation is `LogSink.x`. Everything above is the -public, stable API; everything below is replaceable. - -## Ordering of work - -This is the order I expect the work to land in. The repository currently sits at the end -of step 2. - -1. **Build skeleton.** `build.gradle.kts`, empty `logging.x`, registration in - `xdk/settings.gradle.kts`, `gradle/libs.versions.toml`, `xdk/build.gradle.kts`. - ✅ Done. -2. **Stub the API.** `Level`, `Marker`, `MarkerFactory`, `MDC`, `LogEvent`, `LogSink`, - `Logger`, `LoggingEventBuilder`, `LoggerFactory`, `MessageFormatter`. All compile; - bodies have `TODO(impl)` markers where appropriate. ✅ Done (modulo Ecstasy syntax - review — see `OPEN_QUESTIONS.md`). -3. **Stub the default impls.** `BasicMarker`, `BasicLogger`, `BasicEventBuilder`, - `ConsoleLogSink`, `NoopLogSink`, `MemoryLogSink`. ✅ Done. -4. **Make it build.** Run `./gradlew :lib_logging:build`, fix any Ecstasy syntax issues, - confirm distribution build still works. **In progress.** -5. **Real `MessageFormatter`.** Port the SLF4J state machine for `{}` substitution - including escape rules and throwable promotion. Test against SLF4J's published cases. -6. **Runtime injection plumbing.** Java side. Add a `RTLogger` native service in - `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/` and register it in - `nMainInjector.addNativeResources()`. See `DESIGN.md` and `OPEN_QUESTIONS.md` — there - is a wildcard-name lookup change required. -7. **Sample under `manualTests/`.** A throwaway program that does - `@Inject Logger logger; logger.info(...)` end-to-end through the default - `ConsoleLogSink`. -8. **MDC story.** Decide whether MDC entries are fiber-local or service-instance-local - and document the decision; update the `MDC` service to match. -9. **Compiler-side default-name** (optional). When a user writes `@Inject Logger logger;` - with no resourceName, have the XTC compiler substitute the enclosing module's - qualified name. Strict ergonomics improvement; not blocking. -10. **Future module: `lib_logging_logback`.** Separate module shipping a - configuration-driven `LogSink` with appenders, layouts, filters, per-logger - thresholds. See `LOGBACK_INTEGRATION.md`. - -## Validation checklist (done = "v0 ready to merge") - -- [ ] `./gradlew clean` then `./gradlew :lib_logging:build` succeeds. -- [ ] `./gradlew xdk:installDist` still succeeds and bundles `lib_logging.xtc`. -- [ ] A `manualTests/` sample emits an `info` and `error` event to the console. -- [ ] `MemoryLogSink` test confirms the events captured contain the right - `(loggerName, level, message, exception, marker)`. -- [ ] All existing XDK tests still pass. diff --git a/doc/logging/CLOUD_INTEGRATION.md b/doc/logging/cloud-integration.md similarity index 98% rename from doc/logging/CLOUD_INTEGRATION.md rename to doc/logging/cloud-integration.md index 9396a7b34b..733e14d7a8 100644 --- a/doc/logging/CLOUD_INTEGRATION.md +++ b/doc/logging/cloud-integration.md @@ -1,7 +1,7 @@ # Cloud-native logging — why API familiarity matters This doc explains *why* the choice between SLF4J-shaped and slog-shaped logging -APIs (`lib_logging` vs `lib_slogging`, see `LIB_LOGGING_VS_LIB_SLOGGING.md`) is +APIs (`lib_logging` vs `lib_slogging`, see `lib-logging-vs-lib-slogging.md`) is not just an aesthetic call. It is the entry point to the major cloud observability ecosystems — and the wrong API choice silently rules Ecstasy out of common deployment patterns even when the code itself is fine. @@ -214,7 +214,7 @@ the API doesn't expose them: 5. **An async / batched sink.** When a service emits 10k log events per second, blocking the request thread on disk I/O is unacceptable. Both libraries' `AsyncLogSink` / `AsyncHandler` wrappers (W-5 in - `OPEN_QUESTIONS.md`) cover this; we have not implemented either yet. + `open-questions.md`) cover this; we have not implemented either yet. Note that *all five* of these features exist for the cloud-deployment story, not for the local-tail-the-log-file story. They are exactly the features that diff --git a/doc/logging/CUSTOM_SINKS.md b/doc/logging/custom-sinks.md similarity index 97% rename from doc/logging/CUSTOM_SINKS.md rename to doc/logging/custom-sinks.md index 6074f2787b..530a81621c 100644 --- a/doc/logging/CUSTOM_SINKS.md +++ b/doc/logging/custom-sinks.md @@ -42,7 +42,7 @@ that field to be `Passable`, so every implementation must be either `immutable` If you find yourself reaching for `synchronized` blocks, you want a `service`. If your sink is "given a `Console`/`Writer`/`Function`, format and forward" — you want a `const`. The full rule, with reference examples from the wider XDK / platform -ecosystem, is in `DESIGN.md` ("Sink type: `const` vs `service`"). +ecosystem, is in `design.md` ("Sink type: `const` vs `service`"). ## A worked example: a counting sink @@ -69,7 +69,7 @@ service CountingLogSink } ``` -Wiring it up — see `OPEN_QUESTIONS.md` for the runtime side; for now, you can construct +Wiring it up — see `open-questions.md` for the runtime side; for now, you can construct a `BasicLogger` directly: ```ecstasy @@ -253,7 +253,7 @@ service JsonLineLogSink @Override void log(LogEvent event) { - // See STRUCTURED_LOGGING.md for the full sketch — emits one JSON object per line. + // See structured-logging.md for the full sketch — emits one JSON object per line. } } ``` diff --git a/doc/logging/DESIGN.md b/doc/logging/design.md similarity index 86% rename from doc/logging/DESIGN.md rename to doc/logging/design.md index e56cbfc1f2..0afbbcae86 100644 --- a/doc/logging/DESIGN.md +++ b/doc/logging/design.md @@ -51,7 +51,7 @@ static Logger PaymentLogger = logger.named("payments"); This mirrors SLF4J's `LoggerFactory.getLogger(MyClass.class)` idiom one-to-one, and keeps the injector's resource table as the single registry of allowed injections — no type-only wildcard fallback, no special-case for `Logger`. See -`RUNTIME_IMPLEMENTATION_PLAN.md` Stage 1.4 for the full rationale. +`runtime-implementation-plan.md` Stage 1.4 for the full rationale. ## API surface @@ -171,7 +171,7 @@ service/const distinction makes the intent explicit at the type level — and is by the compiler whenever a sink is held by a `const` such as `BasicLogger`. Open question: is one-interface-with-two-allowed-impl-shapes the right call, or should -`LogSink` split into two interfaces? See `OPEN_QUESTIONS.md` (item 6). +`LogSink` split into two interfaces? See `open-questions.md` (item 6). ## Future: `lib_logging_logback` @@ -185,7 +185,7 @@ A separate module providing a configuration-driven sink. Reads a config tree - async wrappers. The whole thing is just a different `LogSink` implementation. User code that already -worked against `lib_logging` keeps working. Detailed sketch in `LOGBACK_INTEGRATION.md`. +worked against `lib_logging` keeps working. Detailed sketch in `logback-integration.md`. ## Native bridge — could we wrap real Logback? @@ -196,7 +196,7 @@ A `RTLogbackSink.java` extending `nService` and registered in Logback are already in the version catalog (`lang-slf4j`, `lang-logback`), used by the lang tooling. -We don't recommend this as the primary path — see `NATIVE_BRIDGE.md` for the full +We don't recommend this as the primary path — see `native-bridge.md` for the full analysis — but it's a feasible escape hatch and worth documenting because it constrains the design. @@ -205,7 +205,7 @@ the design. Each Ecstasy container has its own injector. Host code that wants a nested container to use a *different* sink than the host's default does so by configuring the child container's injector explicitly — there is no -`lib_logging`-side helper for this, by design (see `OPEN_QUESTIONS.md` item 8). +`lib_logging`-side helper for this, by design (see `open-questions.md` item 8). The pattern matches how every other injectable resource in the XDK is overridden per child container: @@ -244,16 +244,39 @@ convenience because: If a future user needs this often enough that a helper is worth it, the helper lives in the host runtime's injector library, not here. +## Non-goals (v0) + +Items deliberately *not* in scope for the v0 stub of `lib_logging`. Each is +either tracked as Tier 3 in `open-questions.md` or punted on principle. + +- **Distributed tracing context propagation.** `MDC` carries strings; that's + it. A future tracing library can write its trace ID into MDC and let the + logging library carry it, but `lib_logging` itself does not implement + trace/span propagation. +- **Async / batched / buffered sinks.** The `LogSink` contract allows them; + the default sinks shipped here don't do them. The async wrapper sink lives + in the future `lib_logging_logback` (or its own follower module), not the + base library — `open-questions.md` item 7. +- **Configuration file format.** The default sink has one knob (`rootLevel`) + set at construction. A real config story (XML / programmatic / hot reload) + belongs to the future Logback-style backend; `logback-integration.md` + sketches it. +- **Compile-time logger-name defaulting from the enclosing module.** + `@Inject Logger logger;` currently gets the fixed-name root logger; users + derive per-module loggers via `logger.named("...")`. Auto-substitution from + the enclosing module's qualified name is W-4 in `open-questions.md` and + needs an XTC compiler change. + ## What isn't here yet - **Compiler-side default name from module** — `@Inject Logger logger;` (no `resourceName`) currently gets the fixed-name root logger; users derive per-class loggers via `logger.named("...")`. The XTC-compiler change to substitute the - enclosing module's qualified name is `RUNTIME_IMPLEMENTATION_PLAN.md` Stage 4 and + enclosing module's qualified name is `runtime-implementation-plan.md` Stage 4 and remains open. - **`AsyncLogSink` / `lib_logging_logback` / native bridge** — see - `OPEN_QUESTIONS.md` items 7 (async) and the `LOGBACK_INTEGRATION.md` / - `NATIVE_BRIDGE.md` follower-module sketches. + `open-questions.md` items 7 (async) and the `logback-integration.md` / + `native-bridge.md` follower-module sketches. - **Per-container override convenience** — open question 8. The runtime-side injection wiring lives in @@ -261,7 +284,7 @@ The runtime-side injection wiring lives in `ensureConst`); `BasicLogger` is the `const` returned for `@Inject Logger`. The earlier interpose service `xRTLogger.java` was removed in favour of constructing `BasicLogger` directly so MDC fiber-locals survive injection (see Q-D5 in -`OPEN_QUESTIONS.md`). The real `MessageFormatter` is implemented (12 tests in +`open-questions.md`). The real `MessageFormatter` is implemented (12 tests in `MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (51 passing as of this commit). diff --git a/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md b/doc/logging/ecstasy-vs-java-examples.md similarity index 97% rename from doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md rename to doc/logging/ecstasy-vs-java-examples.md index a677e67b41..42585be56e 100644 --- a/doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md +++ b/doc/logging/ecstasy-vs-java-examples.md @@ -210,7 +210,7 @@ import org.slf4j.LoggerFactory; sink.setRootLevel(log.Debug); ``` -For a richer per-logger configuration tree see `LOGBACK_INTEGRATION.md` — the future +For a richer per-logger configuration tree see `logback-integration.md` — the future `lib_logging_logback` module would expose a programmatic and/or file-based config API analogous to Logback's `JoranConfigurator`. @@ -256,7 +256,7 @@ service CountingSink } ``` -Wire it by replacing the injected default sink — see `CUSTOM_SINKS.md` for the runtime +Wire it by replacing the injected default sink — see `custom-sinks.md` for the runtime side of the story. ## 10. NOP / silent logger (for libraries that opt out) diff --git a/doc/logging/INJECTED_LOGGER_EXAMPLE.md b/doc/logging/injected-logger-example.md similarity index 99% rename from doc/logging/INJECTED_LOGGER_EXAMPLE.md rename to doc/logging/injected-logger-example.md index 7c117e633e..a6943c1f44 100644 --- a/doc/logging/INJECTED_LOGGER_EXAMPLE.md +++ b/doc/logging/injected-logger-example.md @@ -26,7 +26,7 @@ Logger payments = logger.named("payments"); // per-name child via the API There is **no** `@Inject("payments") Logger logger;` form. Per-name loggers are *derived* from the injected one, the same way SLF4J users write `LoggerFactory.getLogger(MyClass.class)` once at the top of a class. See -`RUNTIME_IMPLEMENTATION_PLAN.md` Stage 1.4 for why we ruled out wildcard injection. +`runtime-implementation-plan.md` Stage 1.4 for why we ruled out wildcard injection. The examples below `import` the public lib_logging types unqualified by adding type-level imports next to the `package log import …` alias: diff --git a/doc/logging/LAZY_LOGGING.md b/doc/logging/lazy-logging.md similarity index 99% rename from doc/logging/LAZY_LOGGING.md rename to doc/logging/lazy-logging.md index ce3c167ebf..1dc921c066 100644 --- a/doc/logging/LAZY_LOGGING.md +++ b/doc/logging/lazy-logging.md @@ -207,7 +207,7 @@ saying it matters. ## Concrete next step -Add the lambda overloads to `Logger.x` and `BasicLogger.x`. Update `ECSTASY_VS_JAVA_EXAMPLES.md` +Add the lambda overloads to `Logger.x` and `BasicLogger.x`. Update `ecstasy-vs-java-examples.md` with a side-by-side showing: ```kotlin diff --git a/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md b/doc/logging/lib-logging-vs-lib-slogging.md similarity index 96% rename from doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md rename to doc/logging/lib-logging-vs-lib-slogging.md index b123e15b73..023157231e 100644 --- a/doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -29,7 +29,7 @@ This document: > Status note: `lib_slogging` is currently a skeleton (interfaces + a TextHandler stub). > The SLF4J-shaped library is roughly feature-complete (47 unit tests passing). See -> `OPEN_QUESTIONS.md` for the explicit tracking list of items still missing on each +> `open-questions.md` for the explicit tracking list of items still missing on each > side; we do not ask reviewers to compare designs until both libraries reach the same > waterline. @@ -334,7 +334,7 @@ logger.info("login attempt", [ ]); ``` -Multi-marker is what motivated W-1 in `OPEN_QUESTIONS.md`. In slog the +Multi-marker is what motivated W-1 in `open-questions.md`. In slog the question doesn't arise — repeated attrs are the only shape. ##### Pattern 3 — hierarchical relationships ("BREACH is a kind of SECURITY") @@ -622,7 +622,7 @@ attribute model needs less surface area. SLF4J expects `LoggerFactory.getLogger(MyClass.class)` to resolve to a class- or module-named logger. `lib_logging` currently delegates that to a future compiler -change (Stage 4 in `RUNTIME_IMPLEMENTATION_PLAN.md`). Without it, `@Inject Logger` +change (Stage 4 in `runtime-implementation-plan.md`). Without it, `@Inject Logger` gets a fixed-name root logger and you call `.named("...")` to derive children. slog has no such machinery — there is one process-wide default Logger and you derive @@ -710,6 +710,18 @@ optional / never the only path. The reasoning, ranked: `withAttrs(...)` semantics, *opt in*, gives you the SLF4J win without the "everything is implicit" cost. +A third option — **hybrid SLF4J-facade with slog-shaped event model** — is +worth flagging once but not pushing for v0. Concretely: keep `Logger`, +`Marker`, `MDC`, the fluent builder; replace the free-form `Object[] arguments` +on `LogEvent` with a typed `Attr[]`; have `MessageFormatter` resolve `{}` +placeholders against `attrs` by index, but also have a `JsonLineLogSink` +render `attrs` directly as a JSON object. SLF4J users get their familiar +shape; cloud-side JSON pipelines get a clean wire format. The cost is +strictly more complexity than either pure design — two parallel mental +models in one library — so we don't recommend it for v0. The right time to +revisit is after one of the two pure designs is shipping and we know which +axis we wish we had more of. + Counter-arguments worth taking seriously: - **Familiarity.** A great deal of Ecstasy's audience comes from JVM, where SLF4J is @@ -724,7 +736,7 @@ Counter-arguments worth taking seriously: The recommendation is therefore tentative. We expect to revisit it once the reviewers — particularly the XTC language team — weigh in on questions Q-D1 through -Q-D6 in `OPEN_QUESTIONS.md`. +Q-D6 in `open-questions.md`. --- @@ -733,15 +745,15 @@ Q-D6 in `OPEN_QUESTIONS.md`. This branch (`lagergren/logging`) contains: - `lib_logging/` — the SLF4J-shaped library, near-feature-complete (47 unit tests - passing). Tracking list of remaining items: `OPEN_QUESTIONS.md` § "SLF4J-style + passing). Tracking list of remaining items: `open-questions.md` § "SLF4J-style library work not yet implemented". - `lib_slogging/` — the slog-shaped library, currently a **skeleton** (interfaces + one stub `TextHandler`). Full implementation gated on reviewer feedback on this document. - This document — the design comparison and the explicit list of reviewer questions. -- `OPEN_QUESTIONS.md` — the unified question list (all "Q-D*" items added in this PR +- `open-questions.md` — the unified question list (all "Q-D*" items added in this PR call out language-design questions specifically). -- `DESIGN.md` — the SLF4J-side design, now with the resolved sink-type rule. +- `design.md` — the SLF4J-side design, now with the resolved sink-type rule. We do not propose merging until reviewers have weighed in on at least Q-D6 (which API shape) and Q-D1 (sink interface convention). diff --git a/doc/logging/LOGBACK_INTEGRATION.md b/doc/logging/logback-integration.md similarity index 99% rename from doc/logging/LOGBACK_INTEGRATION.md rename to doc/logging/logback-integration.md index ee7a8bc2ba..1e39accfed 100644 --- a/doc/logging/LOGBACK_INTEGRATION.md +++ b/doc/logging/logback-integration.md @@ -58,7 +58,7 @@ This isn't novel — it's exactly Logback's mental model. The advantage of writi an Ecstasy module rather than a wrapper around the JVM Logback library is that it gets to use Ecstasy's own primitives (services for thread-safety, fibers for async, the file abstraction from `lib_ecstasy`) instead of needing the bridge story discussed in -`NATIVE_BRIDGE.md`. +`native-bridge.md`. ## Sketch — the public API of `lib_logging_logback` @@ -201,7 +201,7 @@ behaviour on the next call. ## Per-logger lookup -The longest-prefix-match lookup illustrated in `CUSTOM_SINKS.md` is the engine: +The longest-prefix-match lookup illustrated in `custom-sinks.md` is the engine: ```ecstasy private Level effectiveLevel(String loggerName) { diff --git a/doc/logging/NATIVE_BRIDGE.md b/doc/logging/native-bridge.md similarity index 98% rename from doc/logging/NATIVE_BRIDGE.md rename to doc/logging/native-bridge.md index a216e0acbe..e906ac8332 100644 --- a/doc/logging/NATIVE_BRIDGE.md +++ b/doc/logging/native-bridge.md @@ -88,7 +88,7 @@ suppliers.put(new Resource(logSinkType, "default"), RTLogbackSink::$create); ``` The wildcard `"*"` for `Logger` is the workaround for the current exact-name resolution -(see `OPEN_QUESTIONS.md` — this is a small change to `nMainInjector.supplierOf`). +(see `open-questions.md` — this is a small change to `nMainInjector.supplierOf`). The suppliers map is per-Injector instance, so each container can choose its own sink. There is no JVM-global state; this matches Ecstasy's container isolation @@ -183,7 +183,7 @@ Three options, ordered by recommendation: ### Option A (recommended): pure-Ecstasy `LogSink` is the primary path Ship `ConsoleLogSink` as the default. Ship `lib_logging_logback` (the future module -described in `LOGBACK_INTEGRATION.md`) as a pure-Ecstasy implementation. +described in `logback-integration.md`) as a pure-Ecstasy implementation. A native sink, if we ever ship one, is *one* of multiple `LogSink` choices, not the default. Users who want the Java Logback experience opt in explicitly. diff --git a/doc/logging/OPEN_QUESTIONS.md b/doc/logging/open-questions.md similarity index 93% rename from doc/logging/OPEN_QUESTIONS.md rename to doc/logging/open-questions.md index f5bd284371..660d99d19a 100644 --- a/doc/logging/OPEN_QUESTIONS.md +++ b/doc/logging/open-questions.md @@ -6,7 +6,7 @@ deliberately short so it stays readable as the project moves. It is split into two sections: - **Addressed by the runtime plan** — questions that *had* been open, but - [`RUNTIME_IMPLEMENTATION_PLAN.md`](RUNTIME_IMPLEMENTATION_PLAN.md) now prescribes a + [`runtime-implementation-plan.md`](runtime-implementation-plan.md) now prescribes a concrete fix. Those are summarized in one line each, with a link. - **Still genuinely open** — questions whose answer the plan does not commit to. These retain their full trade-off discussion, because someone will have to decide. @@ -19,12 +19,12 @@ When an "addressed by the plan" item lands in code, delete its row. | # | Question | Resolution | |---|---|---| -| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](RUNTIME_IMPLEMENTATION_PLAN.md#14--single-fixed-name-supplier-no-wildcard-injection). | -| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](RUNTIME_IMPLEMENTATION_PLAN.md#stage-4--compiler-side-default-name-optional-but-high-impact). | -| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](RUNTIME_IMPLEMENTATION_PLAN.md#31--decide-the-scope). | -| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](RUNTIME_IMPLEMENTATION_PLAN.md#21--real-messageformatterformat). | -| 9 | **Where does the runtime live?** | `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/`, registered from `nMainInjector.addNativeResources()`. See [Stage 1.1–1.5](RUNTIME_IMPLEMENTATION_PLAN.md#stage-1--native-side-make-inject-logger-resolve). | -| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Yes — `RTConsoleLogSink.java` falls back to `System.err.println` if `Console` is not yet available. See [Stage 1.2](RUNTIME_IMPLEMENTATION_PLAN.md#12--rtconsolelogsinkjava). | +| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](runtime-implementation-plan.md#14--single-fixed-name-supplier-no-wildcard-injection). | +| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](runtime-implementation-plan.md#stage-4--compiler-side-default-name-optional-but-high-impact). | +| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](runtime-implementation-plan.md#31--decide-the-scope). | +| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](runtime-implementation-plan.md#21--real-messageformatterformat). | +| 9 | **Where does the runtime live?** | `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/`, registered from `nMainInjector.addNativeResources()`. See [Stage 1.1–1.5](runtime-implementation-plan.md#stage-1--native-side-make-inject-logger-resolve). | +| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Yes — `RTConsoleLogSink.java` falls back to `System.err.println` if `Console` is not yet available. See [Stage 1.2](runtime-implementation-plan.md#12--rtconsolelogsinkjava). | --- @@ -49,7 +49,7 @@ and wrap it; multi-marker is reachable through the fluent builder. SPI-level ### 6. `const` vs `service` for sinks — *resolved* **Resolution.** `LogSink` is an interface; implementations choose between `const` and -`service` according to the rule documented in `DESIGN.md` ("Sink type: `const` vs +`service` according to the rule documented in `design.md` ("Sink type: `const` vs `service`"): - stateless forwarders / pure adapters → `const` @@ -62,7 +62,7 @@ signature was rejected because it would prohibit the legitimate stateless cases `class ConsoleLog` pattern already used in `lib_ecstasy/src/main/x/ecstasy/io/`. This rule is checked for parity against patterns in `platform/common`, -`platform/host`, and `lib_xunit_engine` — see DESIGN.md for the citations. +`platform/host`, and `lib_xunit_engine` — see design.md for the citations. ### 7. Async / batched sinks @@ -82,7 +82,7 @@ ship in v0 unchanged. **Resolution.** Adopted option A — document the pattern, add no API. A host that wants a nested container to use a different sink configures the child container's injector explicitly to resolve the `("logger", Logger)` key against the alternate -sink. Pattern documented in `DESIGN.md` § "Per-container sink override". No +sink. Pattern documented in `design.md` § "Per-container sink override". No `lib_logging`-side helper, deliberately: per-container resource overrides are the host runtime / injector library's job, not the logging library's. @@ -115,7 +115,7 @@ who own the language semantics, before locking the API. ### Q-D1. Is "`const` if stateless / `service` if stateful with shared mutable state" the right pattern for an Ecstasy SPI boundary? -We landed on a rule (see DESIGN.md, "Sink type") that lets `LogSink` accept both shapes, +We landed on a rule (see design.md, "Sink type") that lets `LogSink` accept both shapes, mirroring the way `lib_ecstasy/src/main/x/ecstasy/io/ConsoleAppender.x` is a `class` while `service ErrorLog` and `service ConsoleExecutionListener` exist elsewhere. The alternative is forcing every implementation to be one shape (e.g. `LogSink extends @@ -180,7 +180,7 @@ visibility to survive injection? If so, should `nMainInjector` grow a We're maintaining two parallel libraries (`lib_logging` modeled after SLF4J 2.x and `lib_slogging` modeled after Go's `slog`) so the design tradeoffs can be evaluated -side-by-side. See `LIB_LOGGING_VS_LIB_SLOGGING.md` for the comparison and the explicit +side-by-side. See `lib-logging-vs-lib-slogging.md` for the comparison and the explicit list of things we want reviewer feedback on. **Question:** which API style is a better long-term fit for Ecstasy idioms (services, @@ -225,15 +225,15 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | W-3 | Real `MessageFormatter` | **Done.** Full SLF4J ParameterFormatter parity: `{}` substitution, `\{}` literal escape, `\\{}` double-escape, trailing-throwable promotion (with explicit-cause precedence enforced in `BasicLogger.emit`), defensive `safeToString`. 12 tests in `MessageFormatterTest`. The "stub" claim in earlier docs was stale. | | W-4 | Compiler-side default name from module | `@Inject Logger logger;` (no resourceName) should fall back to the enclosing module's qualified name; see plan Stage 4. | | W-5 | Async / batched sink (`AsyncLogSink`) | Bounded queue + worker fiber; lives in `lib_logging_logback` not in the base lib. Open question 7. | -| W-6 | Logback-shaped backend (`lib_logging_logback`) | Configuration-driven sink with per-logger thresholds, multiple appenders, layouts, filters. Sketched in `LOGBACK_INTEGRATION.md`. | -| W-7 | Native bridge (`RTLogbackSink.java`) | Optional escape hatch wrapping real Logback via `javatools_jitbridge`. Sketched in `NATIVE_BRIDGE.md`. | +| W-6 | Logback-shaped backend (`lib_logging_logback`) | Configuration-driven sink with per-logger thresholds, multiple appenders, layouts, filters. Sketched in `logback-integration.md`. | +| W-7 | Native bridge (`RTLogbackSink.java`) | Optional escape hatch wrapping real Logback via `javatools_jitbridge`. Sketched in `native-bridge.md`. | | W-8 | Per-container override convenience | Helper for child containers wanting a different sink. Open question 8. | | W-9 | Defensive copy of caller-supplied `Object[] arguments` | Open question 11. Decision: B (document, no copy) for v0. Listed here so it's not forgotten if v0 changes. | | W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | -| W-11 | Doc cleanup | `DESIGN.md` "What isn't here yet" still lists items that have since landed (RTLogger removed, MessageFormatter partial, tests now exist); `RUNTIME_IMPLEMENTATION_PLAN.md` and `INJECTED_LOGGER_EXAMPLE.md` may reference the now-removed `xRTLogger`/`RTLogger.x`. | +| W-11 | Doc cleanup | `design.md` "What isn't here yet" still lists items that have since landed (RTLogger removed, MessageFormatter partial, tests now exist); `runtime-implementation-plan.md` and `injected-logger-example.md` may reference the now-removed `xRTLogger`/`RTLogger.x`. | The same tracking shape will live for `lib_slogging` in -`LIB_LOGGING_VS_LIB_SLOGGING.md` so reviewers can see the two libraries reach feature +`lib-logging-vs-lib-slogging.md` so reviewers can see the two libraries reach feature parity at the same waterline before we ask "which API do you prefer?" ### Implementation tiers diff --git a/doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md b/doc/logging/platform-and-examples-adaptation.md similarity index 100% rename from doc/logging/PLATFORM_AND_EXAMPLES_ADAPTATION.md rename to doc/logging/platform-and-examples-adaptation.md diff --git a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md b/doc/logging/runtime-implementation-plan.md similarity index 94% rename from doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md rename to doc/logging/runtime-implementation-plan.md index c3a8de2acc..d8aae7c420 100644 --- a/doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md +++ b/doc/logging/runtime-implementation-plan.md @@ -8,7 +8,7 @@ > directly in `javatools/.../NativeContainer.java` (`ensureLogger` / > `ensureConst`). The reason — collapsing the service-wrapper indirection so > per-fiber `MDC` (`SharedContext`) survives injection — is documented as -> question Q-D5 in `OPEN_QUESTIONS.md`. The stage descriptions below describe +> question Q-D5 in `open-questions.md`. The stage descriptions below describe > the *original* approach for historical context; treat them as background, not > as instructions for current work. Stage 4 (compiler-side default name) > remains open. @@ -25,7 +25,7 @@ producing real output on the platform `Console` via the runtime injector. Every here either is on the critical path to that demo, or is needed to call the round trip "complete" (parameterized messages actually substitute, MDC actually propagates, etc.). -The doc supersedes `OPEN_QUESTIONS.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete +The doc supersedes `open-questions.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete fixes; the others remain genuinely open. ## Definition of "demo worthy" @@ -274,12 +274,12 @@ location in the compiler; the dispatch on type `Logger` is the only special case #### 4.2 — Document -Update `INJECTED_LOGGER_EXAMPLE.md` and `SLF4J_PARITY.md` to reflect the implicit- +Update `injected-logger-example.md` and `slf4j-parity.md` to reflect the implicit- naming behaviour. **Done state of Stage 4:** `@Inject Logger logger;` inside `module PaymentService` emits lines tagged `PaymentService:`. The cheat sheet in -`INJECTED_LOGGER_EXAMPLE.md` becomes accurate without `@Inject("PaymentService")`. +`injected-logger-example.md` becomes accurate without `@Inject("PaymentService")`. This stage is **optional for the demo**. Without it, callers write `@Inject Logger logger; Logger paymentLogger = logger.named("PaymentService");`. With @@ -292,7 +292,7 @@ The end-to-end test of "demo worthy" is migrating real code. #### 5.1 — Pick three files in `~/src/platform` `kernel/kernel.x`, `auth/OAuthProvider.x`, `host/HostManager.x` are the highest- -density `console.print($"... Info :")` files. See `PLATFORM_AND_EXAMPLES_ADAPTATION.md`. +density `console.print($"... Info :")` files. See `platform-and-examples-adaptation.md`. #### 5.2 — Migrate each @@ -314,24 +314,24 @@ call. The before/after diff is the demo. The plan above closes runtime-side gaps. These items remain genuinely unresolved and need a decision separately: -- **Multiple markers per event** (`OPEN_QUESTIONS.md` #4). API-level: should we change +- **Multiple markers per event** (`open-questions.md` #4). API-level: should we change `LogEvent.marker: Marker?` to `LogEvent.markers: Marker[]` and update the fluent builder to accumulate? Cheap to do now, breaks callers later. -- **Service vs class for sinks** (`OPEN_QUESTIONS.md` #6). Currently the recommendation +- **Service vs class for sinks** (`open-questions.md` #6). Currently the recommendation is "don't require service"; the question is whether to formalize that in the interface signature. -- **Async / batched sinks** (`OPEN_QUESTIONS.md` #7). Defer to `lib_logging_logback`? +- **Async / batched sinks** (`open-questions.md` #7). Defer to `lib_logging_logback`? Or ship a simple `AsyncLogSink` wrapper here? -- **Per-container override convenience** (`OPEN_QUESTIONS.md` #8). Probably "no +- **Per-container override convenience** (`open-questions.md` #8). Probably "no helper, document the pattern" — but worth deciding before there are users. -- **Defensive copy of caller `Object[]`** (`OPEN_QUESTIONS.md` #11). Now that args are +- **Defensive copy of caller `Object[]`** (`open-questions.md` #11). Now that args are frozen-on-the-way-into-the-event for builder calls, the per-level methods (which accept caller-supplied arrays) might still see mutation. Decide: copy in `BasicLogger.emit` always, or document caller-must-not-mutate. -- **Lazy logging lambdas** (`LAZY_LOGGING.md`). When do we add the +- **Lazy logging lambdas** (`lazy-logging.md`). When do we add the `info(function String () messageFn, ...)` overloads? Easy to add; question is timing. -- **Structured `keyValues` field on `LogEvent`** (`STRUCTURED_LOGGING.md`). Add now or +- **Structured `keyValues` field on `LogEvent`** (`structured-logging.md`). Add now or with the first sink that consumes them? ## Recommended sequencing @@ -362,9 +362,9 @@ platform migration are independent and can land later. ## What this plan does *not* cover -- The future `lib_logging_logback` module (`LOGBACK_INTEGRATION.md`). That's a +- The future `lib_logging_logback` module (`logback-integration.md`). That's a separate, larger project for a configurable hierarchical backend. -- The native-Logback bridge approach (`NATIVE_BRIDGE.md`). Documented as feasible but +- The native-Logback bridge approach (`native-bridge.md`). Documented as feasible but not the primary path. - Slog-style alternative API (`ALTERNATIVE_DESIGN_SLOG_STYLE.md`). Documented as a thinkable alternative but not pursued. diff --git a/doc/logging/SLF4J_PARITY.md b/doc/logging/slf4j-parity.md similarity index 97% rename from doc/logging/SLF4J_PARITY.md rename to doc/logging/slf4j-parity.md index 4c4c29d7e5..2d5e4c3382 100644 --- a/doc/logging/SLF4J_PARITY.md +++ b/doc/logging/slf4j-parity.md @@ -13,7 +13,7 @@ scan this once and know everything they need. | (no equivalent) | `@Inject Logger logger;` *(injects a single root logger)* | `@Inject("com.example.foo") Logger logger;` was considered and rejected — see -`RUNTIME_IMPLEMENTATION_PLAN.md` Stage 1.4 for why. SLF4J doesn't have a +`runtime-implementation-plan.md` Stage 1.4 for why. SLF4J doesn't have a parameterized injection annotation either; `LoggerFactory.getLogger(MyClass.class)` is its idiom and `Logger.named(String)` is the direct Ecstasy equivalent. @@ -116,7 +116,7 @@ SLF4J's `{}` placeholder semantics are reproduced verbatim by `MessageFormatter. - Native injection (`@Inject Logger logger;`). SLF4J has nothing like this; it relies on the static factory. Injection makes per-container sink override trivial — see - `OPEN_QUESTIONS.md` for the open piece around per-container overrides. + `open-questions.md` for the open piece around per-container overrides. - `Off` level, useful for sink configuration. SLF4J expresses this through level checks in `Logger.isEnabledFor(...)`. diff --git a/doc/logging/STRUCTURED_LOGGING.md b/doc/logging/structured-logging.md similarity index 98% rename from doc/logging/STRUCTURED_LOGGING.md rename to doc/logging/structured-logging.md index 075b66f1a9..978313105c 100644 --- a/doc/logging/STRUCTURED_LOGGING.md +++ b/doc/logging/structured-logging.md @@ -99,7 +99,7 @@ break when richer sinks arrive. The `LogEvent` const should grow a `keyValues` field analogous to SLF4J's `getKeyValuePairs()`. The current stub does not have it; this is one of the v0.1 follow-ups -tracked in `OPEN_QUESTIONS.md`. The intended shape: +tracked in `open-questions.md`. The intended shape: ```ecstasy const LogEvent( @@ -201,7 +201,7 @@ changes, and the LogSink choice on the deployment side. The bigger story — encoder/appender wiring, log aggregator integration — is handled by the structured sink (`JsonLineLogSink` in the simple case, a future Logback-bridge sink -in the complex case). See `LOGBACK_INTEGRATION.md`. +in the complex case). See `logback-integration.md`. ## What to build first vs. later diff --git a/doc/logging/WHY_SLF4J_AND_INJECTION.md b/doc/logging/why-slf4j-and-injection.md similarity index 100% rename from doc/logging/WHY_SLF4J_AND_INJECTION.md rename to doc/logging/why-slf4j-and-injection.md diff --git a/doc/logging/XDK_ALIGNMENT.md b/doc/logging/xdk-alignment.md similarity index 99% rename from doc/logging/XDK_ALIGNMENT.md rename to doc/logging/xdk-alignment.md index 475fc6fab9..8c1828c063 100644 --- a/doc/logging/XDK_ALIGNMENT.md +++ b/doc/logging/xdk-alignment.md @@ -199,7 +199,7 @@ we do things." The only thing missing for full first-class status is the runtime-side resource registration (`@Inject Logger` actually resolving), which is the next chunk of work -described in `OPEN_QUESTIONS.md` items 1, 2, and 9. +described in `open-questions.md` items 1, 2, and 9. --- diff --git a/lib_logging/README.md b/lib_logging/README.md index 1f1f793a34..710261d1b5 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -26,13 +26,13 @@ without touching caller code. All design docs live at the repo root under [`doc/logging/`](../doc/logging): - [`PLAN.md`](../doc/logging/PLAN.md) — master plan, scope, ordering of work -- [`DESIGN.md`](../doc/logging/DESIGN.md) — architecture, module layout, API↔impl boundary -- [`SLF4J_PARITY.md`](../doc/logging/SLF4J_PARITY.md) — every SLF4J 2.x type and method, mapped -- [`ECSTASY_VS_JAVA_EXAMPLES.md`](../doc/logging/ECSTASY_VS_JAVA_EXAMPLES.md) — Java SLF4J +- [`design.md`](../doc/logging/design.md) — architecture, module layout, API↔impl boundary +- [`slf4j-parity.md`](../doc/logging/slf4j-parity.md) — every SLF4J 2.x type and method, mapped +- [`ecstasy-vs-java-examples.md`](../doc/logging/ecstasy-vs-java-examples.md) — Java SLF4J example, then the same thing in Ecstasy, for every API -- [`CUSTOM_SINKS.md`](../doc/logging/CUSTOM_SINKS.md) — guide to writing your own sink -- [`LOGBACK_INTEGRATION.md`](../doc/logging/LOGBACK_INTEGRATION.md) — how a future +- [`custom-sinks.md`](../doc/logging/custom-sinks.md) — guide to writing your own sink +- [`logback-integration.md`](../doc/logging/logback-integration.md) — how a future logback-style configuration-driven backend would fit -- [`NATIVE_BRIDGE.md`](../doc/logging/NATIVE_BRIDGE.md) — could we plug real Java logging +- [`native-bridge.md`](../doc/logging/native-bridge.md) — could we plug real Java logging libraries in via native code? Investigation and recommendation -- [`OPEN_QUESTIONS.md`](../doc/logging/OPEN_QUESTIONS.md) — things still to decide +- [`open-questions.md`](../doc/logging/open-questions.md) — things still to decide diff --git a/lib_logging/src/main/x/logging.x b/lib_logging/src/main/x/logging.x index 266bd44922..b7429e3059 100644 --- a/lib_logging/src/main/x/logging.x +++ b/lib_logging/src/main/x/logging.x @@ -40,7 +40,7 @@ * - [MemoryLogSink] — captures events in memory; useful in tests * * A future `lib_logging_logback` module is expected to ship a configuration-driven sink with - * appenders, layouts, filters, and per-logger thresholds — see `docs/LOGBACK_INTEGRATION.md`. + * appenders, layouts, filters, and per-logger thresholds — see `docs/logback-integration.md`. */ module logging.xtclang.org { } diff --git a/lib_logging/src/main/x/logging/BasicLogger.x b/lib_logging/src/main/x/logging/BasicLogger.x index 6eda98680d..1de8d655d9 100644 --- a/lib_logging/src/main/x/logging/BasicLogger.x +++ b/lib_logging/src/main/x/logging/BasicLogger.x @@ -154,7 +154,7 @@ const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) */ void emitWith(Level level, String message, Object[] arguments, Exception? cause, Marker[] markers, Map keyValues) { - // v0 policy on `arguments`: no defensive copy. Per `OPEN_QUESTIONS.md` Q11 the + // v0 policy on `arguments`: no defensive copy. Per `open-questions.md` Q11 the // caller is contractually required not to mutate the array between the return of // `info(...)` and any (possibly async) sink consuming the resulting `LogEvent`. // Matches SLF4J's posture; `BasicEventBuilder` already freezes its accumulating diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x index 0745735e18..b340ef0b1e 100644 --- a/lib_logging/src/main/x/logging/ConsoleLogSink.x +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -25,11 +25,11 @@ * Sinks that *do* hold mutable shared state (e.g. `MemoryLogSink` collecting events, * a future `FileLogSink` owning a `Writer`, a future `AsyncLogSink` owning a worker * queue) must remain `service`. The rule of thumb is documented in - * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + * `doc/logging/design.md` ("Sink type: `const` vs `service`"). * * Configuration is intentionally minimal: a single `rootLevel` threshold applied to every * logger. Per-logger / per-marker filtering is the job of richer sinks (see - * `doc/logging/LOGBACK_INTEGRATION.md`). + * `doc/logging/logback-integration.md`). */ const ConsoleLogSink(Level rootLevel) implements LogSink { diff --git a/lib_logging/src/main/x/logging/LogSink.x b/lib_logging/src/main/x/logging/LogSink.x index 219dcb7fc1..8e1d31f737 100644 --- a/lib_logging/src/main/x/logging/LogSink.x +++ b/lib_logging/src/main/x/logging/LogSink.x @@ -7,7 +7,7 @@ * `LogSink` is more like the Logback `Appender`: a single emission target with its own * level filter. Mapping multiple `LogSink`s onto one logger (Logback's "appender attached * to logger" model) is the job of a future composite sink — see - * `doc/logging/LOGBACK_INTEGRATION.md`. + * `doc/logging/logback-integration.md`. * * The Service-Provider Interface for logging backends. A `LogSink` is the only thing a * `Logger` ever talks to; everything else (level checks, message formatting, marker @@ -30,7 +30,7 @@ * - `log` receives a fully-formed `LogEvent`. The message has already had `{}` placeholders * substituted; the `mdcSnapshot` has already been captured. * - * See `docs/CUSTOM_SINKS.md` for a worked example. + * See `docs/custom-sinks.md` for a worked example. * * # Choosing between `const` and `service` for an implementation * @@ -48,7 +48,7 @@ * * The full rule, with reference examples from the platform/xunit codebases (e.g. * `service ConsoleExecutionListener`, `service ErrorLog`), is in - * `doc/logging/DESIGN.md` under "Sink type: `const` vs `service`". + * `doc/logging/design.md` under "Sink type: `const` vs `service`". */ interface LogSink { diff --git a/lib_logging/src/main/x/logging/Marker.x b/lib_logging/src/main/x/logging/Marker.x index b9f455eb34..8111c13b43 100644 --- a/lib_logging/src/main/x/logging/Marker.x +++ b/lib_logging/src/main/x/logging/Marker.x @@ -29,7 +29,7 @@ * The default implementation in this module ([BasicMarker]) is intentionally minimal — the * in-memory marker shipped here exists primarily so that user code that *uses* markers * compiles and runs against the default sink (which mostly ignores them). Real filtering is - * the job of richer sinks (see `doc/logging/LOGBACK_INTEGRATION.md`). + * the job of richer sinks (see `doc/logging/logback-integration.md`). */ interface Marker extends Freezable diff --git a/lib_logging/src/main/x/logging/MemoryLogSink.x b/lib_logging/src/main/x/logging/MemoryLogSink.x index 97d53973f6..0dca7848e8 100644 --- a/lib_logging/src/main/x/logging/MemoryLogSink.x +++ b/lib_logging/src/main/x/logging/MemoryLogSink.x @@ -18,7 +18,7 @@ * the assertion code that reads back the events). That is structurally identical to * `service ConsoleExecutionListener` in `lib_xunit_engine` and `service ErrorLog` in * `platform/common` — both are stateful event collectors. See - * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`") for the full rule. + * `doc/logging/design.md` ("Sink type: `const` vs `service`") for the full rule. */ service MemoryLogSink implements LogSink { diff --git a/lib_logging/src/main/x/logging/NoopLogSink.x b/lib_logging/src/main/x/logging/NoopLogSink.x index b837a127a4..838230e43d 100644 --- a/lib_logging/src/main/x/logging/NoopLogSink.x +++ b/lib_logging/src/main/x/logging/NoopLogSink.x @@ -15,7 +15,7 @@ * `NoopLogSink` is purely stateless — `isEnabled` always returns `False` and `log` is * intentionally empty. There is nothing to mutate, nothing to share, nothing to fan in * from many fibers. That is the canonical case for `const`: cheap to construct, cheap - * to pass across service boundaries, no scheduler overhead. See `doc/logging/DESIGN.md` + * to pass across service boundaries, no scheduler overhead. See `doc/logging/design.md` * ("Sink type: `const` vs `service`") for the full rule. */ const NoopLogSink diff --git a/lib_logging/src/test/x/LoggingTest/CountingSink.x b/lib_logging/src/test/x/LoggingTest/CountingSink.x index 89f3b0268a..df2b8ec61b 100644 --- a/lib_logging/src/test/x/LoggingTest/CountingSink.x +++ b/lib_logging/src/test/x/LoggingTest/CountingSink.x @@ -5,7 +5,7 @@ import logging.Marker; /** * Test-only sink: counts events by level. Useful as a reference example for users - * writing their own custom sinks — see `doc/logging/CUSTOM_SINKS.md`. + * writing their own custom sinks — see `doc/logging/custom-sinks.md`. */ service CountingSink implements LogSink { diff --git a/lib_logging/src/test/x/LoggingTest/ListLogSink.x b/lib_logging/src/test/x/LoggingTest/ListLogSink.x index c06ed86ac0..10b9943073 100644 --- a/lib_logging/src/test/x/LoggingTest/ListLogSink.x +++ b/lib_logging/src/test/x/LoggingTest/ListLogSink.x @@ -11,7 +11,7 @@ import logging.Marker; * `rootLevel` defaults to `Trace` so by default every emitted event is captured. * * Modelled as a `service` because it carries mutable state (`events[]`) shared across - * the fiber-under-test and the assertion code. See `doc/logging/DESIGN.md` + * the fiber-under-test and the assertion code. See `doc/logging/design.md` * ("Sink type: `const` vs `service`"). */ service ListLogSink diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index 386b1b0f0e..c393d4a87f 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -32,7 +32,7 @@ * **This module is currently a skeleton** — interfaces, the `Logger` const, and stub * handlers (`TextHandler`, `NopHandler`, `MemoryHandler`, `JSONHandler`). Full * implementation is gated on reviewer feedback in - * `doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md` so we don't sink effort into both shapes + * `doc/logging/lib-logging-vs-lib-slogging.md` so we don't sink effort into both shapes * before deciding which one Ecstasy should adopt. * * # API / Implementation boundary @@ -52,8 +52,8 @@ * * # See also * - * doc/logging/LIB_LOGGING_VS_LIB_SLOGGING.md — the design comparison document - * doc/logging/OPEN_QUESTIONS.md — list of reviewer questions (Q-D6) + * doc/logging/lib-logging-vs-lib-slogging.md — the design comparison document + * doc/logging/open-questions.md — list of reviewer questions (Q-D6) * lib_logging/src/main/x/logging.x — the SLF4J-shaped sibling library */ module slogging.xtclang.org { diff --git a/lib_slogging/src/main/x/slogging/Attr.x b/lib_slogging/src/main/x/slogging/Attr.x index 92ec0daf6f..254c49cb57 100644 --- a/lib_slogging/src/main/x/slogging/Attr.x +++ b/lib_slogging/src/main/x/slogging/Attr.x @@ -19,7 +19,7 @@ * In Ecstasy we accept any `Object` here and let the handler do the case split via * `value.is(...)`. This is closer to how the SLF4J library carries `Map` * and avoids inventing a parallel kind-tag enum. The trade-off — slightly more work in - * each handler — is documented in `LIB_LOGGING_VS_LIB_SLOGGING.md` § 3.5. + * each handler — is documented in `lib-logging-vs-lib-slogging.md` § 3.5. * * Values must be `Passable` (`immutable` or a service) because `Attr` is a `const` and * its fields are auto-frozen on construction. Trying to put a mutable class instance in diff --git a/lib_slogging/src/main/x/slogging/Handler.x b/lib_slogging/src/main/x/slogging/Handler.x index dea08f67ad..de5cd64f9c 100644 --- a/lib_slogging/src/main/x/slogging/Handler.x +++ b/lib_slogging/src/main/x/slogging/Handler.x @@ -32,7 +32,7 @@ * * `Logger` is a `const` and references its `handler` through this interface, so every * concrete `Handler` must be `Passable` (`immutable` or a `service`). The full rule and - * the citations to `lib_xunit_engine` / `platform/common` are in `DESIGN.md` ("Sink + * the citations to `lib_xunit_engine` / `platform/common` are in `design.md` ("Sink * type: `const` vs `service`") — same rule, both libraries. */ interface Handler { diff --git a/lib_slogging/src/main/x/slogging/Logger.x b/lib_slogging/src/main/x/slogging/Logger.x index e9727c0276..7a746cf9b6 100644 --- a/lib_slogging/src/main/x/slogging/Logger.x +++ b/lib_slogging/src/main/x/slogging/Logger.x @@ -37,7 +37,7 @@ * construction, forward to handler). Source-location capture is not. There is no * `LogAttrs(ctx, level, msg, attrs...)` form yet — the per-level methods cover the same * ground in v0; `ctx`-style propagation is the open Q-D6.b discussion in - * `OPEN_QUESTIONS.md`. + * `open-questions.md`. */ const Logger(Handler handler, Attr[] attrs) implements Orderable { diff --git a/lib_slogging/src/main/x/slogging/MemoryHandler.x b/lib_slogging/src/main/x/slogging/MemoryHandler.x index 8823470a68..29d2d6ef80 100644 --- a/lib_slogging/src/main/x/slogging/MemoryHandler.x +++ b/lib_slogging/src/main/x/slogging/MemoryHandler.x @@ -14,7 +14,7 @@ * It accumulates records. The backing array is mutated on every `handle()` call and * the same instance is shared across the logger-under-test fiber and the assertion * fiber. Same reasoning as `lib_logging`'s `MemoryLogSink`. See - * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + * `doc/logging/design.md` ("Sink type: `const` vs `service`"). */ service MemoryHandler implements Handler { diff --git a/lib_slogging/src/main/x/slogging/NopHandler.x b/lib_slogging/src/main/x/slogging/NopHandler.x index c3393f938f..19cff516d5 100644 --- a/lib_slogging/src/main/x/slogging/NopHandler.x +++ b/lib_slogging/src/main/x/slogging/NopHandler.x @@ -9,7 +9,7 @@ * # Why this handler is a `const` * * Identical reasoning to `lib_logging`'s `NoopLogSink`: no state, no shared mutation, - * pure forwarder. See `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + * pure forwarder. See `doc/logging/design.md` ("Sink type: `const` vs `service`"). */ const NopHandler implements Handler { diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index 6c03f85e19..59e7b0c948 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -14,14 +14,14 @@ * shape end to end (`Logger.with(...)`, attribute folding into namespaced keys, level * threshold filtering) but not yet a production formatter. Specifically, no escaping * of `=` / `"` in attribute values, no group nesting collapse beyond one level, no - * timestamp formatting options. Tracked in `OPEN_QUESTIONS.md` § "lib_slogging work + * timestamp formatting options. Tracked in `open-questions.md` § "lib_slogging work * not yet implemented" once the parity table is added. * * # Why this handler is a `const`, not a `service` * * Same rule as `lib_logging`'s `ConsoleLogSink`: stateless forwarder over `@Inject * Console`. Threshold fixed at construction. No accumulation, no shared mutable state. - * See `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`") for the full rule. + * See `doc/logging/design.md` ("Sink type: `const` vs `service`") for the full rule. */ const TextHandler(Level rootLevel, String groupPrefix) implements Handler { diff --git a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x index 60b8d22c74..9b5a96c6bf 100644 --- a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x @@ -12,7 +12,7 @@ import slogging.Record; * * Modelled as a `service` because it carries mutable state (the records list) shared * across the fiber-under-test and the assertion code. See - * `doc/logging/DESIGN.md` ("Sink type: `const` vs `service`"). + * `doc/logging/design.md` ("Sink type: `const` vs `service`"). */ service ListHandler implements Handler { diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x index b0d3eea043..db1638a1e6 100644 --- a/manualTests/src/main/x/TestLogger.x +++ b/manualTests/src/main/x/TestLogger.x @@ -92,7 +92,7 @@ module TestLogger { * class). The derived logger shares this logger's sink, so all configuration applied * to the default logger flows through to its descendants. * - * Bare-essentials demo target (`doc/logging/RUNTIME_IMPLEMENTATION_PLAN.md`): the + * Bare-essentials demo target (`doc/logging/runtime-implementation-plan.md`): the * message must print as `hello world` (formatted by `MessageFormatter`), not * `hello {}` (raw). The logger-name column on the resulting line should read `Demo`. */ From 139b37db2f8e8dca09184e88e0832ce9e676333e Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Fri, 1 May 2026 22:34:53 +0200 Subject: [PATCH 08/28] doc/logging: group docs into design/, usage/, future/ subdirectories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen markdown files at one level was a wall of text — a flat ls of the directory was overwhelming even with the curated README index. Grouped them by purpose into three subdirectories so the on-disk layout matches how the README organises them, and so a casual reader browsing the repo on GitHub sees a tree, not a wall. Top-level (4 read-first docs): - README.md — entry point - lib-logging-vs-lib-slogging.md — headline comparison - cloud-integration.md — deployment context - open-questions.md — live tracker / reviewer questions design/ (architecture + rationale): - design.md - why-slf4j-and-injection.md - xdk-alignment.md usage/ (how-to + reference + examples): - injected-logger-example.md - ecstasy-vs-java-examples.md - slf4j-parity.md - platform-and-examples-adaptation.md - custom-sinks.md - structured-logging.md future/ (Tier 3 + explorations): - logback-integration.md - native-bridge.md - lazy-logging.md - runtime-implementation-plan.md Every cross-reference (markdown links + .x source comments + .java + .kts) was rewritten to use the correct relative path from each file's new location: in-subdir docs use ../ to reach top-level, top-level docs include the subdir prefix. Verified zero broken links across the tree. Tests still green: 51 + 22. --- doc/logging/README.md | 44 +++++++++---------- doc/logging/{ => design}/design.md | 28 ++++++------ .../{ => design}/why-slf4j-and-injection.md | 2 +- doc/logging/{ => design}/xdk-alignment.md | 4 +- doc/logging/{ => future}/lazy-logging.md | 4 +- .../{ => future}/logback-integration.md | 6 +-- doc/logging/{ => future}/native-bridge.md | 6 +-- .../runtime-implementation-plan.md | 30 ++++++------- doc/logging/lib-logging-vs-lib-slogging.md | 4 +- doc/logging/open-questions.md | 28 ++++++------ doc/logging/{ => usage}/custom-sinks.md | 8 ++-- .../{ => usage}/ecstasy-vs-java-examples.md | 6 +-- .../{ => usage}/injected-logger-example.md | 4 +- .../platform-and-examples-adaptation.md | 2 +- doc/logging/{ => usage}/slf4j-parity.md | 6 +-- doc/logging/{ => usage}/structured-logging.md | 6 +-- lib_logging/README.md | 12 ++--- .../src/main/x/logging/ConsoleLogSink.x | 4 +- lib_logging/src/main/x/logging/LogSink.x | 4 +- lib_logging/src/main/x/logging/Marker.x | 2 +- .../src/main/x/logging/MemoryLogSink.x | 2 +- lib_logging/src/main/x/logging/NoopLogSink.x | 2 +- .../src/test/x/LoggingTest/CountingSink.x | 2 +- .../src/test/x/LoggingTest/ListLogSink.x | 2 +- .../src/main/x/slogging/MemoryHandler.x | 2 +- lib_slogging/src/main/x/slogging/NopHandler.x | 2 +- .../src/main/x/slogging/TextHandler.x | 2 +- .../src/test/x/SLoggingTest/ListHandler.x | 2 +- manualTests/src/main/x/TestLogger.x | 2 +- 29 files changed, 114 insertions(+), 114 deletions(-) rename doc/logging/{ => design}/design.md (92%) rename doc/logging/{ => design}/why-slf4j-and-injection.md (99%) rename doc/logging/{ => design}/xdk-alignment.md (98%) rename doc/logging/{ => future}/lazy-logging.md (98%) rename doc/logging/{ => future}/logback-integration.md (98%) rename doc/logging/{ => future}/native-bridge.md (97%) rename doc/logging/{ => future}/runtime-implementation-plan.md (92%) rename doc/logging/{ => usage}/custom-sinks.md (96%) rename doc/logging/{ => usage}/ecstasy-vs-java-examples.md (96%) rename doc/logging/{ => usage}/injected-logger-example.md (97%) rename doc/logging/{ => usage}/platform-and-examples-adaptation.md (99%) rename doc/logging/{ => usage}/slf4j-parity.md (96%) rename doc/logging/{ => usage}/structured-logging.md (97%) diff --git a/doc/logging/README.md b/doc/logging/README.md index c5dba9d4f1..87d425016f 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -46,11 +46,11 @@ support material. ### I'm an SLF4J / Logback Java engineer — show me what changes -1. **[`ecstasy-vs-java-examples.md`](ecstasy-vs-java-examples.md)** — every +1. **[`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md)** — every SLF4J idiom with the equivalent Ecstasy code beside it. -2. **[`slf4j-parity.md`](slf4j-parity.md)** — exhaustive type-by-type and +2. **[`usage/slf4j-parity.md`](usage/slf4j-parity.md)** — exhaustive type-by-type and method-by-method mapping. Reference, not narrative. -3. **[`injected-logger-example.md`](injected-logger-example.md)** — a complete +3. **[`usage/injected-logger-example.md`](usage/injected-logger-example.md)** — a complete end-to-end example of `@Inject Logger logger;` with a runnable companion at `manualTests/src/main/x/TestLogger.x`. @@ -68,15 +68,15 @@ support material. 1. **[`open-questions.md`](open-questions.md)** §§ "Questions for the XTC language / runtime designers" (Q-D1..Q-D7). Each question is tied to a concrete piece of code we wrote and includes the workaround we adopted. -2. **[`design.md`](design.md)** — `lib_logging` architecture, including the +2. **[`design/design.md`](design/design.md)** — `lib_logging` architecture, including the `const` vs `service` rule for sinks and the per-fiber `MDC` mechanism. ### I want to write my own sink / handler -1. **[`custom-sinks.md`](custom-sinks.md)** — guide to writing a custom +1. **[`usage/custom-sinks.md`](usage/custom-sinks.md)** — guide to writing a custom `LogSink`, with worked examples (counting, file, hierarchical, tee, marker-filtering, JSON-line). Includes the `const` vs `service` decision. -2. **[`structured-logging.md`](structured-logging.md)** — how SLF4J 2.x +2. **[`usage/structured-logging.md`](usage/structured-logging.md)** — how SLF4J 2.x structured logging maps onto `lib_logging`, with a sketch of a JSON sink. ### I want to deploy this to GCP / AWS / Azure @@ -84,14 +84,14 @@ support material. 1. **[`cloud-integration.md`](cloud-integration.md)** — adapter graphs for the three major clouds, with concrete examples and the table-stakes API features each cloud product depends on. -2. **[`logback-integration.md`](logback-integration.md)** — sketch of a future +2. **[`future/logback-integration.md`](future/logback-integration.md)** — sketch of a future configuration-driven binding (Tier 3 work, not in this PR). -3. **[`native-bridge.md`](native-bridge.md)** — could we instead plug *real +3. **[`future/native-bridge.md`](future/native-bridge.md)** — could we instead plug *real Java Logback* in via the JIT bridge? Investigation, evidence, recommendation. ### I want to migrate existing Ecstasy code that already does ad-hoc logging -1. **[`platform-and-examples-adaptation.md`](platform-and-examples-adaptation.md)** +1. **[`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md)** — surveys `~/src/platform` and `~/src/examples` and shows how their `console.print($"... Info :")` patterns would migrate. @@ -105,22 +105,22 @@ support material. | [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | | [`open-questions.md`](open-questions.md) | Live tracking: open questions, designer questions (Q-D1..Q-D7), W-item parity list, implementation tiers. | | **Design (`lib_logging` side)** | | -| [`design.md`](design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | -| [`why-slf4j-and-injection.md`](why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | -| [`xdk-alignment.md`](xdk-alignment.md) | Alignment with the conventions of other XDK libraries (`lib_ecstasy`, `lib_cli`, etc.). | +| [`design/design.md`](design/design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | +| [`design/why-slf4j-and-injection.md`](design/why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | +| [`design/xdk-alignment.md`](design/xdk-alignment.md) | Alignment with the conventions of other XDK libraries (`lib_ecstasy`, `lib_cli`, etc.). | | **Usage / examples** | | -| [`injected-logger-example.md`](injected-logger-example.md) | End-to-end working example of `@Inject Logger`. | -| [`ecstasy-vs-java-examples.md`](ecstasy-vs-java-examples.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | -| [`slf4j-parity.md`](slf4j-parity.md) | Exhaustive type/method mapping reference. | -| [`platform-and-examples-adaptation.md`](platform-and-examples-adaptation.md) | Migration survey for existing Ecstasy code bases. | +| [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | End-to-end working example of `@Inject Logger`. | +| [`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | +| [`usage/slf4j-parity.md`](usage/slf4j-parity.md) | Exhaustive type/method mapping reference. | +| [`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md) | Migration survey for existing Ecstasy code bases. | | **Extension** | | -| [`custom-sinks.md`](custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | -| [`structured-logging.md`](structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | +| [`usage/custom-sinks.md`](usage/custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | +| [`usage/structured-logging.md`](usage/structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | | **Tier 3 / future work** | | -| [`logback-integration.md`](logback-integration.md) | Sketch of a configuration-driven Logback-style binding. | -| [`native-bridge.md`](native-bridge.md) | Wrapping real Java Logback through the JIT bridge — feasibility analysis. | -| [`lazy-logging.md`](lazy-logging.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | -| [`runtime-implementation-plan.md`](runtime-implementation-plan.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | +| [`future/logback-integration.md`](future/logback-integration.md) | Sketch of a configuration-driven Logback-style binding. | +| [`future/native-bridge.md`](future/native-bridge.md) | Wrapping real Java Logback through the JIT bridge — feasibility analysis. | +| [`future/lazy-logging.md`](future/lazy-logging.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | +| [`future/runtime-implementation-plan.md`](future/runtime-implementation-plan.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | ## Where the actual code lives diff --git a/doc/logging/design.md b/doc/logging/design/design.md similarity index 92% rename from doc/logging/design.md rename to doc/logging/design/design.md index 0afbbcae86..f0a9151d76 100644 --- a/doc/logging/design.md +++ b/doc/logging/design/design.md @@ -51,7 +51,7 @@ static Logger PaymentLogger = logger.named("payments"); This mirrors SLF4J's `LoggerFactory.getLogger(MyClass.class)` idiom one-to-one, and keeps the injector's resource table as the single registry of allowed injections — no type-only wildcard fallback, no special-case for `Logger`. See -`runtime-implementation-plan.md` Stage 1.4 for the full rationale. +`../future/runtime-implementation-plan.md` Stage 1.4 for the full rationale. ## API surface @@ -171,7 +171,7 @@ service/const distinction makes the intent explicit at the type level — and is by the compiler whenever a sink is held by a `const` such as `BasicLogger`. Open question: is one-interface-with-two-allowed-impl-shapes the right call, or should -`LogSink` split into two interfaces? See `open-questions.md` (item 6). +`LogSink` split into two interfaces? See `../open-questions.md` (item 6). ## Future: `lib_logging_logback` @@ -185,7 +185,7 @@ A separate module providing a configuration-driven sink. Reads a config tree - async wrappers. The whole thing is just a different `LogSink` implementation. User code that already -worked against `lib_logging` keeps working. Detailed sketch in `logback-integration.md`. +worked against `lib_logging` keeps working. Detailed sketch in `../future/logback-integration.md`. ## Native bridge — could we wrap real Logback? @@ -196,7 +196,7 @@ A `RTLogbackSink.java` extending `nService` and registered in Logback are already in the version catalog (`lang-slf4j`, `lang-logback`), used by the lang tooling. -We don't recommend this as the primary path — see `native-bridge.md` for the full +We don't recommend this as the primary path — see `../future/native-bridge.md` for the full analysis — but it's a feasible escape hatch and worth documenting because it constrains the design. @@ -205,7 +205,7 @@ the design. Each Ecstasy container has its own injector. Host code that wants a nested container to use a *different* sink than the host's default does so by configuring the child container's injector explicitly — there is no -`lib_logging`-side helper for this, by design (see `open-questions.md` item 8). +`lib_logging`-side helper for this, by design (see `../open-questions.md` item 8). The pattern matches how every other injectable resource in the XDK is overridden per child container: @@ -247,7 +247,7 @@ helper lives in the host runtime's injector library, not here. ## Non-goals (v0) Items deliberately *not* in scope for the v0 stub of `lib_logging`. Each is -either tracked as Tier 3 in `open-questions.md` or punted on principle. +either tracked as Tier 3 in `../open-questions.md` or punted on principle. - **Distributed tracing context propagation.** `MDC` carries strings; that's it. A future tracing library can write its trace ID into MDC and let the @@ -256,15 +256,15 @@ either tracked as Tier 3 in `open-questions.md` or punted on principle. - **Async / batched / buffered sinks.** The `LogSink` contract allows them; the default sinks shipped here don't do them. The async wrapper sink lives in the future `lib_logging_logback` (or its own follower module), not the - base library — `open-questions.md` item 7. + base library — `../open-questions.md` item 7. - **Configuration file format.** The default sink has one knob (`rootLevel`) set at construction. A real config story (XML / programmatic / hot reload) - belongs to the future Logback-style backend; `logback-integration.md` + belongs to the future Logback-style backend; `../future/logback-integration.md` sketches it. - **Compile-time logger-name defaulting from the enclosing module.** `@Inject Logger logger;` currently gets the fixed-name root logger; users derive per-module loggers via `logger.named("...")`. Auto-substitution from - the enclosing module's qualified name is W-4 in `open-questions.md` and + the enclosing module's qualified name is W-4 in `../open-questions.md` and needs an XTC compiler change. ## What isn't here yet @@ -272,11 +272,11 @@ either tracked as Tier 3 in `open-questions.md` or punted on principle. - **Compiler-side default name from module** — `@Inject Logger logger;` (no `resourceName`) currently gets the fixed-name root logger; users derive per-class loggers via `logger.named("...")`. The XTC-compiler change to substitute the - enclosing module's qualified name is `runtime-implementation-plan.md` Stage 4 and + enclosing module's qualified name is `../future/runtime-implementation-plan.md` Stage 4 and remains open. - **`AsyncLogSink` / `lib_logging_logback` / native bridge** — see - `open-questions.md` items 7 (async) and the `logback-integration.md` / - `native-bridge.md` follower-module sketches. + `../open-questions.md` items 7 (async) and the `../future/logback-integration.md` / + `../future/native-bridge.md` follower-module sketches. - **Per-container override convenience** — open question 8. The runtime-side injection wiring lives in @@ -284,11 +284,11 @@ The runtime-side injection wiring lives in `ensureConst`); `BasicLogger` is the `const` returned for `@Inject Logger`. The earlier interpose service `xRTLogger.java` was removed in favour of constructing `BasicLogger` directly so MDC fiber-locals survive injection (see Q-D5 in -`open-questions.md`). The real `MessageFormatter` is implemented (12 tests in +`../open-questions.md`). The real `MessageFormatter` is implemented (12 tests in `MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (51 passing as of this commit). --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/why-slf4j-and-injection.md b/doc/logging/design/why-slf4j-and-injection.md similarity index 99% rename from doc/logging/why-slf4j-and-injection.md rename to doc/logging/design/why-slf4j-and-injection.md index 60182a7d0f..08f86503a2 100644 --- a/doc/logging/why-slf4j-and-injection.md +++ b/doc/logging/design/why-slf4j-and-injection.md @@ -325,4 +325,4 @@ This is what "instantly familiar to all SLF4J users *and* better" looks like. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/xdk-alignment.md b/doc/logging/design/xdk-alignment.md similarity index 98% rename from doc/logging/xdk-alignment.md rename to doc/logging/design/xdk-alignment.md index 8c1828c063..0434233391 100644 --- a/doc/logging/xdk-alignment.md +++ b/doc/logging/design/xdk-alignment.md @@ -199,9 +199,9 @@ we do things." The only thing missing for full first-class status is the runtime-side resource registration (`@Inject Logger` actually resolving), which is the next chunk of work -described in `open-questions.md` items 1, 2, and 9. +described in `../open-questions.md` items 1, 2, and 9. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/lazy-logging.md b/doc/logging/future/lazy-logging.md similarity index 98% rename from doc/logging/lazy-logging.md rename to doc/logging/future/lazy-logging.md index 1dc921c066..322082e114 100644 --- a/doc/logging/lazy-logging.md +++ b/doc/logging/future/lazy-logging.md @@ -207,7 +207,7 @@ saying it matters. ## Concrete next step -Add the lambda overloads to `Logger.x` and `BasicLogger.x`. Update `ecstasy-vs-java-examples.md` +Add the lambda overloads to `Logger.x` and `BasicLogger.x`. Update `../usage/ecstasy-vs-java-examples.md` with a side-by-side showing: ```kotlin @@ -245,4 +245,4 @@ This is consistent with picking Option 1 and only revisiting if data demands it. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/logback-integration.md b/doc/logging/future/logback-integration.md similarity index 98% rename from doc/logging/logback-integration.md rename to doc/logging/future/logback-integration.md index 1e39accfed..fa13005b4a 100644 --- a/doc/logging/logback-integration.md +++ b/doc/logging/future/logback-integration.md @@ -58,7 +58,7 @@ This isn't novel — it's exactly Logback's mental model. The advantage of writi an Ecstasy module rather than a wrapper around the JVM Logback library is that it gets to use Ecstasy's own primitives (services for thread-safety, fibers for async, the file abstraction from `lib_ecstasy`) instead of needing the bridge story discussed in -`native-bridge.md`. +`../future/native-bridge.md`. ## Sketch — the public API of `lib_logging_logback` @@ -201,7 +201,7 @@ behaviour on the next call. ## Per-logger lookup -The longest-prefix-match lookup illustrated in `custom-sinks.md` is the engine: +The longest-prefix-match lookup illustrated in `../usage/custom-sinks.md` is the engine: ```ecstasy private Level effectiveLevel(String loggerName) { @@ -290,4 +290,4 @@ without API changes. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/native-bridge.md b/doc/logging/future/native-bridge.md similarity index 97% rename from doc/logging/native-bridge.md rename to doc/logging/future/native-bridge.md index e906ac8332..c1bd5f8de6 100644 --- a/doc/logging/native-bridge.md +++ b/doc/logging/future/native-bridge.md @@ -88,7 +88,7 @@ suppliers.put(new Resource(logSinkType, "default"), RTLogbackSink::$create); ``` The wildcard `"*"` for `Logger` is the workaround for the current exact-name resolution -(see `open-questions.md` — this is a small change to `nMainInjector.supplierOf`). +(see `../open-questions.md` — this is a small change to `nMainInjector.supplierOf`). The suppliers map is per-Injector instance, so each container can choose its own sink. There is no JVM-global state; this matches Ecstasy's container isolation @@ -183,7 +183,7 @@ Three options, ordered by recommendation: ### Option A (recommended): pure-Ecstasy `LogSink` is the primary path Ship `ConsoleLogSink` as the default. Ship `lib_logging_logback` (the future module -described in `logback-integration.md`) as a pure-Ecstasy implementation. +described in `../future/logback-integration.md`) as a pure-Ecstasy implementation. A native sink, if we ever ship one, is *one* of multiple `LogSink` choices, not the default. Users who want the Java Logback experience opt in explicitly. @@ -229,4 +229,4 @@ The most important property is that none of these decisions break caller code. T --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/runtime-implementation-plan.md b/doc/logging/future/runtime-implementation-plan.md similarity index 92% rename from doc/logging/runtime-implementation-plan.md rename to doc/logging/future/runtime-implementation-plan.md index d8aae7c420..281cc12bb0 100644 --- a/doc/logging/runtime-implementation-plan.md +++ b/doc/logging/future/runtime-implementation-plan.md @@ -8,7 +8,7 @@ > directly in `javatools/.../NativeContainer.java` (`ensureLogger` / > `ensureConst`). The reason — collapsing the service-wrapper indirection so > per-fiber `MDC` (`SharedContext`) survives injection — is documented as -> question Q-D5 in `open-questions.md`. The stage descriptions below describe +> question Q-D5 in `../open-questions.md`. The stage descriptions below describe > the *original* approach for historical context; treat them as background, not > as instructions for current work. Stage 4 (compiler-side default name) > remains open. @@ -25,7 +25,7 @@ producing real output on the platform `Console` via the runtime injector. Every here either is on the critical path to that demo, or is needed to call the round trip "complete" (parameterized messages actually substitute, MDC actually propagates, etc.). -The doc supersedes `open-questions.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete +The doc supersedes `../open-questions.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete fixes; the others remain genuinely open. ## Definition of "demo worthy" @@ -274,12 +274,12 @@ location in the compiler; the dispatch on type `Logger` is the only special case #### 4.2 — Document -Update `injected-logger-example.md` and `slf4j-parity.md` to reflect the implicit- +Update `../usage/injected-logger-example.md` and `../usage/slf4j-parity.md` to reflect the implicit- naming behaviour. **Done state of Stage 4:** `@Inject Logger logger;` inside `module PaymentService` emits lines tagged `PaymentService:`. The cheat sheet in -`injected-logger-example.md` becomes accurate without `@Inject("PaymentService")`. +`../usage/injected-logger-example.md` becomes accurate without `@Inject("PaymentService")`. This stage is **optional for the demo**. Without it, callers write `@Inject Logger logger; Logger paymentLogger = logger.named("PaymentService");`. With @@ -292,7 +292,7 @@ The end-to-end test of "demo worthy" is migrating real code. #### 5.1 — Pick three files in `~/src/platform` `kernel/kernel.x`, `auth/OAuthProvider.x`, `host/HostManager.x` are the highest- -density `console.print($"... Info :")` files. See `platform-and-examples-adaptation.md`. +density `console.print($"... Info :")` files. See `../usage/platform-and-examples-adaptation.md`. #### 5.2 — Migrate each @@ -314,24 +314,24 @@ call. The before/after diff is the demo. The plan above closes runtime-side gaps. These items remain genuinely unresolved and need a decision separately: -- **Multiple markers per event** (`open-questions.md` #4). API-level: should we change +- **Multiple markers per event** (`../open-questions.md` #4). API-level: should we change `LogEvent.marker: Marker?` to `LogEvent.markers: Marker[]` and update the fluent builder to accumulate? Cheap to do now, breaks callers later. -- **Service vs class for sinks** (`open-questions.md` #6). Currently the recommendation +- **Service vs class for sinks** (`../open-questions.md` #6). Currently the recommendation is "don't require service"; the question is whether to formalize that in the interface signature. -- **Async / batched sinks** (`open-questions.md` #7). Defer to `lib_logging_logback`? +- **Async / batched sinks** (`../open-questions.md` #7). Defer to `lib_logging_logback`? Or ship a simple `AsyncLogSink` wrapper here? -- **Per-container override convenience** (`open-questions.md` #8). Probably "no +- **Per-container override convenience** (`../open-questions.md` #8). Probably "no helper, document the pattern" — but worth deciding before there are users. -- **Defensive copy of caller `Object[]`** (`open-questions.md` #11). Now that args are +- **Defensive copy of caller `Object[]`** (`../open-questions.md` #11). Now that args are frozen-on-the-way-into-the-event for builder calls, the per-level methods (which accept caller-supplied arrays) might still see mutation. Decide: copy in `BasicLogger.emit` always, or document caller-must-not-mutate. -- **Lazy logging lambdas** (`lazy-logging.md`). When do we add the +- **Lazy logging lambdas** (`../future/lazy-logging.md`). When do we add the `info(function String () messageFn, ...)` overloads? Easy to add; question is timing. -- **Structured `keyValues` field on `LogEvent`** (`structured-logging.md`). Add now or +- **Structured `keyValues` field on `LogEvent`** (`../usage/structured-logging.md`). Add now or with the first sink that consumes them? ## Recommended sequencing @@ -362,9 +362,9 @@ platform migration are independent and can land later. ## What this plan does *not* cover -- The future `lib_logging_logback` module (`logback-integration.md`). That's a +- The future `lib_logging_logback` module (`../future/logback-integration.md`). That's a separate, larger project for a configurable hierarchical backend. -- The native-Logback bridge approach (`native-bridge.md`). Documented as feasible but +- The native-Logback bridge approach (`../future/native-bridge.md`). Documented as feasible but not the primary path. - Slog-style alternative API (`ALTERNATIVE_DESIGN_SLOG_STYLE.md`). Documented as a thinkable alternative but not pursued. @@ -387,4 +387,4 @@ After that, the platform-repo migration PR is the demo. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index 023157231e..8fb90b25ca 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -622,7 +622,7 @@ attribute model needs less surface area. SLF4J expects `LoggerFactory.getLogger(MyClass.class)` to resolve to a class- or module-named logger. `lib_logging` currently delegates that to a future compiler -change (Stage 4 in `runtime-implementation-plan.md`). Without it, `@Inject Logger` +change (Stage 4 in `future/runtime-implementation-plan.md`). Without it, `@Inject Logger` gets a fixed-name root logger and you call `.named("...")` to derive children. slog has no such machinery — there is one process-wide default Logger and you derive @@ -753,7 +753,7 @@ This branch (`lagergren/logging`) contains: - This document — the design comparison and the explicit list of reviewer questions. - `open-questions.md` — the unified question list (all "Q-D*" items added in this PR call out language-design questions specifically). -- `design.md` — the SLF4J-side design, now with the resolved sink-type rule. +- `design/design.md` — the SLF4J-side design, now with the resolved sink-type rule. We do not propose merging until reviewers have weighed in on at least Q-D6 (which API shape) and Q-D1 (sink interface convention). diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index 660d99d19a..b5232e7966 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -6,7 +6,7 @@ deliberately short so it stays readable as the project moves. It is split into two sections: - **Addressed by the runtime plan** — questions that *had* been open, but - [`runtime-implementation-plan.md`](runtime-implementation-plan.md) now prescribes a + [`future/runtime-implementation-plan.md`](future/runtime-implementation-plan.md) now prescribes a concrete fix. Those are summarized in one line each, with a link. - **Still genuinely open** — questions whose answer the plan does not commit to. These retain their full trade-off discussion, because someone will have to decide. @@ -19,12 +19,12 @@ When an "addressed by the plan" item lands in code, delete its row. | # | Question | Resolution | |---|---|---| -| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](runtime-implementation-plan.md#14--single-fixed-name-supplier-no-wildcard-injection). | -| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](runtime-implementation-plan.md#stage-4--compiler-side-default-name-optional-but-high-impact). | -| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](runtime-implementation-plan.md#31--decide-the-scope). | -| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](runtime-implementation-plan.md#21--real-messageformatterformat). | -| 9 | **Where does the runtime live?** | `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/`, registered from `nMainInjector.addNativeResources()`. See [Stage 1.1–1.5](runtime-implementation-plan.md#stage-1--native-side-make-inject-logger-resolve). | -| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Yes — `RTConsoleLogSink.java` falls back to `System.err.println` if `Console` is not yet available. See [Stage 1.2](runtime-implementation-plan.md#12--rtconsolelogsinkjava). | +| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](future/runtime-implementation-plan.md#14--single-fixed-name-supplier-no-wildcard-injection). | +| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](future/runtime-implementation-plan.md#stage-4--compiler-side-default-name-optional-but-high-impact). | +| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](future/runtime-implementation-plan.md#31--decide-the-scope). | +| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](future/runtime-implementation-plan.md#21--real-messageformatterformat). | +| 9 | **Where does the runtime live?** | `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/`, registered from `nMainInjector.addNativeResources()`. See [Stage 1.1–1.5](future/runtime-implementation-plan.md#stage-1--native-side-make-inject-logger-resolve). | +| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Yes — `RTConsoleLogSink.java` falls back to `System.err.println` if `Console` is not yet available. See [Stage 1.2](future/runtime-implementation-plan.md#12--rtconsolelogsinkjava). | --- @@ -49,7 +49,7 @@ and wrap it; multi-marker is reachable through the fluent builder. SPI-level ### 6. `const` vs `service` for sinks — *resolved* **Resolution.** `LogSink` is an interface; implementations choose between `const` and -`service` according to the rule documented in `design.md` ("Sink type: `const` vs +`service` according to the rule documented in `design/design.md` ("Sink type: `const` vs `service`"): - stateless forwarders / pure adapters → `const` @@ -62,7 +62,7 @@ signature was rejected because it would prohibit the legitimate stateless cases `class ConsoleLog` pattern already used in `lib_ecstasy/src/main/x/ecstasy/io/`. This rule is checked for parity against patterns in `platform/common`, -`platform/host`, and `lib_xunit_engine` — see design.md for the citations. +`platform/host`, and `lib_xunit_engine` — see design/design.md for the citations. ### 7. Async / batched sinks @@ -82,7 +82,7 @@ ship in v0 unchanged. **Resolution.** Adopted option A — document the pattern, add no API. A host that wants a nested container to use a different sink configures the child container's injector explicitly to resolve the `("logger", Logger)` key against the alternate -sink. Pattern documented in `design.md` § "Per-container sink override". No +sink. Pattern documented in `design/design.md` § "Per-container sink override". No `lib_logging`-side helper, deliberately: per-container resource overrides are the host runtime / injector library's job, not the logging library's. @@ -115,7 +115,7 @@ who own the language semantics, before locking the API. ### Q-D1. Is "`const` if stateless / `service` if stateful with shared mutable state" the right pattern for an Ecstasy SPI boundary? -We landed on a rule (see design.md, "Sink type") that lets `LogSink` accept both shapes, +We landed on a rule (see design/design.md, "Sink type") that lets `LogSink` accept both shapes, mirroring the way `lib_ecstasy/src/main/x/ecstasy/io/ConsoleAppender.x` is a `class` while `service ErrorLog` and `service ConsoleExecutionListener` exist elsewhere. The alternative is forcing every implementation to be one shape (e.g. `LogSink extends @@ -225,12 +225,12 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | W-3 | Real `MessageFormatter` | **Done.** Full SLF4J ParameterFormatter parity: `{}` substitution, `\{}` literal escape, `\\{}` double-escape, trailing-throwable promotion (with explicit-cause precedence enforced in `BasicLogger.emit`), defensive `safeToString`. 12 tests in `MessageFormatterTest`. The "stub" claim in earlier docs was stale. | | W-4 | Compiler-side default name from module | `@Inject Logger logger;` (no resourceName) should fall back to the enclosing module's qualified name; see plan Stage 4. | | W-5 | Async / batched sink (`AsyncLogSink`) | Bounded queue + worker fiber; lives in `lib_logging_logback` not in the base lib. Open question 7. | -| W-6 | Logback-shaped backend (`lib_logging_logback`) | Configuration-driven sink with per-logger thresholds, multiple appenders, layouts, filters. Sketched in `logback-integration.md`. | -| W-7 | Native bridge (`RTLogbackSink.java`) | Optional escape hatch wrapping real Logback via `javatools_jitbridge`. Sketched in `native-bridge.md`. | +| W-6 | Logback-shaped backend (`lib_logging_logback`) | Configuration-driven sink with per-logger thresholds, multiple appenders, layouts, filters. Sketched in `future/logback-integration.md`. | +| W-7 | Native bridge (`RTLogbackSink.java`) | Optional escape hatch wrapping real Logback via `javatools_jitbridge`. Sketched in `future/native-bridge.md`. | | W-8 | Per-container override convenience | Helper for child containers wanting a different sink. Open question 8. | | W-9 | Defensive copy of caller-supplied `Object[] arguments` | Open question 11. Decision: B (document, no copy) for v0. Listed here so it's not forgotten if v0 changes. | | W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | -| W-11 | Doc cleanup | `design.md` "What isn't here yet" still lists items that have since landed (RTLogger removed, MessageFormatter partial, tests now exist); `runtime-implementation-plan.md` and `injected-logger-example.md` may reference the now-removed `xRTLogger`/`RTLogger.x`. | +| W-11 | Doc cleanup | `design/design.md` "What isn't here yet" still lists items that have since landed (RTLogger removed, MessageFormatter partial, tests now exist); `future/runtime-implementation-plan.md` and `usage/injected-logger-example.md` may reference the now-removed `xRTLogger`/`RTLogger.x`. | The same tracking shape will live for `lib_slogging` in `lib-logging-vs-lib-slogging.md` so reviewers can see the two libraries reach feature diff --git a/doc/logging/custom-sinks.md b/doc/logging/usage/custom-sinks.md similarity index 96% rename from doc/logging/custom-sinks.md rename to doc/logging/usage/custom-sinks.md index 530a81621c..02cca53845 100644 --- a/doc/logging/custom-sinks.md +++ b/doc/logging/usage/custom-sinks.md @@ -42,7 +42,7 @@ that field to be `Passable`, so every implementation must be either `immutable` If you find yourself reaching for `synchronized` blocks, you want a `service`. If your sink is "given a `Console`/`Writer`/`Function`, format and forward" — you want a `const`. The full rule, with reference examples from the wider XDK / platform -ecosystem, is in `design.md` ("Sink type: `const` vs `service`"). +ecosystem, is in `../design/design.md` ("Sink type: `const` vs `service`"). ## A worked example: a counting sink @@ -69,7 +69,7 @@ service CountingLogSink } ``` -Wiring it up — see `open-questions.md` for the runtime side; for now, you can construct +Wiring it up — see `../open-questions.md` for the runtime side; for now, you can construct a `BasicLogger` directly: ```ecstasy @@ -253,7 +253,7 @@ service JsonLineLogSink @Override void log(LogEvent event) { - // See structured-logging.md for the full sketch — emits one JSON object per line. + // See ../usage/structured-logging.md for the full sketch — emits one JSON object per line. } } ``` @@ -292,4 +292,4 @@ service JsonLineLogSink --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/ecstasy-vs-java-examples.md b/doc/logging/usage/ecstasy-vs-java-examples.md similarity index 96% rename from doc/logging/ecstasy-vs-java-examples.md rename to doc/logging/usage/ecstasy-vs-java-examples.md index 42585be56e..382a9ad9f6 100644 --- a/doc/logging/ecstasy-vs-java-examples.md +++ b/doc/logging/usage/ecstasy-vs-java-examples.md @@ -210,7 +210,7 @@ import org.slf4j.LoggerFactory; sink.setRootLevel(log.Debug); ``` -For a richer per-logger configuration tree see `logback-integration.md` — the future +For a richer per-logger configuration tree see `../future/logback-integration.md` — the future `lib_logging_logback` module would expose a programmatic and/or file-based config API analogous to Logback's `JoranConfigurator`. @@ -256,7 +256,7 @@ service CountingSink } ``` -Wire it by replacing the injected default sink — see `custom-sinks.md` for the runtime +Wire it by replacing the injected default sink — see `../usage/custom-sinks.md` for the runtime side of the story. ## 10. NOP / silent logger (for libraries that opt out) @@ -348,4 +348,4 @@ The semantics are the same — `MessageFormatter` is a port of SLF4J's reference --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md similarity index 97% rename from doc/logging/injected-logger-example.md rename to doc/logging/usage/injected-logger-example.md index a6943c1f44..a69cdcf647 100644 --- a/doc/logging/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -26,7 +26,7 @@ Logger payments = logger.named("payments"); // per-name child via the API There is **no** `@Inject("payments") Logger logger;` form. Per-name loggers are *derived* from the injected one, the same way SLF4J users write `LoggerFactory.getLogger(MyClass.class)` once at the top of a class. See -`runtime-implementation-plan.md` Stage 1.4 for why we ruled out wildcard injection. +`../future/runtime-implementation-plan.md` Stage 1.4 for why we ruled out wildcard injection. The examples below `import` the public lib_logging types unqualified by adding type-level imports next to the `package log import …` alias: @@ -249,4 +249,4 @@ That covers ~95% of real-world logging use. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/platform-and-examples-adaptation.md b/doc/logging/usage/platform-and-examples-adaptation.md similarity index 99% rename from doc/logging/platform-and-examples-adaptation.md rename to doc/logging/usage/platform-and-examples-adaptation.md index 93d3c1a4d0..7fdb46e8a0 100644 --- a/doc/logging/platform-and-examples-adaptation.md +++ b/doc/logging/usage/platform-and-examples-adaptation.md @@ -274,4 +274,4 @@ educational rather than operational. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/slf4j-parity.md b/doc/logging/usage/slf4j-parity.md similarity index 96% rename from doc/logging/slf4j-parity.md rename to doc/logging/usage/slf4j-parity.md index 2d5e4c3382..fd1c0b08be 100644 --- a/doc/logging/slf4j-parity.md +++ b/doc/logging/usage/slf4j-parity.md @@ -13,7 +13,7 @@ scan this once and know everything they need. | (no equivalent) | `@Inject Logger logger;` *(injects a single root logger)* | `@Inject("com.example.foo") Logger logger;` was considered and rejected — see -`runtime-implementation-plan.md` Stage 1.4 for why. SLF4J doesn't have a +`../future/runtime-implementation-plan.md` Stage 1.4 for why. SLF4J doesn't have a parameterized injection annotation either; `LoggerFactory.getLogger(MyClass.class)` is its idiom and `Logger.named(String)` is the direct Ecstasy equivalent. @@ -116,11 +116,11 @@ SLF4J's `{}` placeholder semantics are reproduced verbatim by `MessageFormatter. - Native injection (`@Inject Logger logger;`). SLF4J has nothing like this; it relies on the static factory. Injection makes per-container sink override trivial — see - `open-questions.md` for the open piece around per-container overrides. + `../open-questions.md` for the open piece around per-container overrides. - `Off` level, useful for sink configuration. SLF4J expresses this through level checks in `Logger.isEnabledFor(...)`. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/structured-logging.md b/doc/logging/usage/structured-logging.md similarity index 97% rename from doc/logging/structured-logging.md rename to doc/logging/usage/structured-logging.md index 978313105c..c099411fbc 100644 --- a/doc/logging/structured-logging.md +++ b/doc/logging/usage/structured-logging.md @@ -99,7 +99,7 @@ break when richer sinks arrive. The `LogEvent` const should grow a `keyValues` field analogous to SLF4J's `getKeyValuePairs()`. The current stub does not have it; this is one of the v0.1 follow-ups -tracked in `open-questions.md`. The intended shape: +tracked in `../open-questions.md`. The intended shape: ```ecstasy const LogEvent( @@ -201,7 +201,7 @@ changes, and the LogSink choice on the deployment side. The bigger story — encoder/appender wiring, log aggregator integration — is handled by the structured sink (`JsonLineLogSink` in the simple case, a future Logback-bridge sink -in the complex case). See `logback-integration.md`. +in the complex case). See `../future/logback-integration.md`. ## What to build first vs. later @@ -219,4 +219,4 @@ is already shaped to support it without changes. --- -_See also [README.md](README.md) for the full doc index and reading paths._ +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/lib_logging/README.md b/lib_logging/README.md index 710261d1b5..42cfa8de27 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -26,13 +26,13 @@ without touching caller code. All design docs live at the repo root under [`doc/logging/`](../doc/logging): - [`PLAN.md`](../doc/logging/PLAN.md) — master plan, scope, ordering of work -- [`design.md`](../doc/logging/design.md) — architecture, module layout, API↔impl boundary -- [`slf4j-parity.md`](../doc/logging/slf4j-parity.md) — every SLF4J 2.x type and method, mapped -- [`ecstasy-vs-java-examples.md`](../doc/logging/ecstasy-vs-java-examples.md) — Java SLF4J +- [`design.md`](../doc/logging/design/design.md) — architecture, module layout, API↔impl boundary +- [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) — every SLF4J 2.x type and method, mapped +- [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) — Java SLF4J example, then the same thing in Ecstasy, for every API -- [`custom-sinks.md`](../doc/logging/custom-sinks.md) — guide to writing your own sink -- [`logback-integration.md`](../doc/logging/logback-integration.md) — how a future +- [`custom-sinks.md`](../doc/logging/usage/custom-sinks.md) — guide to writing your own sink +- [`logback-integration.md`](../doc/logging/future/logback-integration.md) — how a future logback-style configuration-driven backend would fit -- [`native-bridge.md`](../doc/logging/native-bridge.md) — could we plug real Java logging +- [`native-bridge.md`](../doc/logging/future/native-bridge.md) — could we plug real Java logging libraries in via native code? Investigation and recommendation - [`open-questions.md`](../doc/logging/open-questions.md) — things still to decide diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x index b340ef0b1e..e1e5431f56 100644 --- a/lib_logging/src/main/x/logging/ConsoleLogSink.x +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -25,11 +25,11 @@ * Sinks that *do* hold mutable shared state (e.g. `MemoryLogSink` collecting events, * a future `FileLogSink` owning a `Writer`, a future `AsyncLogSink` owning a worker * queue) must remain `service`. The rule of thumb is documented in - * `doc/logging/design.md` ("Sink type: `const` vs `service`"). + * `doc/logging/design/design.md` ("Sink type: `const` vs `service`"). * * Configuration is intentionally minimal: a single `rootLevel` threshold applied to every * logger. Per-logger / per-marker filtering is the job of richer sinks (see - * `doc/logging/logback-integration.md`). + * `doc/logging/future/logback-integration.md`). */ const ConsoleLogSink(Level rootLevel) implements LogSink { diff --git a/lib_logging/src/main/x/logging/LogSink.x b/lib_logging/src/main/x/logging/LogSink.x index 8e1d31f737..33508c0b4c 100644 --- a/lib_logging/src/main/x/logging/LogSink.x +++ b/lib_logging/src/main/x/logging/LogSink.x @@ -7,7 +7,7 @@ * `LogSink` is more like the Logback `Appender`: a single emission target with its own * level filter. Mapping multiple `LogSink`s onto one logger (Logback's "appender attached * to logger" model) is the job of a future composite sink — see - * `doc/logging/logback-integration.md`. + * `doc/logging/future/logback-integration.md`. * * The Service-Provider Interface for logging backends. A `LogSink` is the only thing a * `Logger` ever talks to; everything else (level checks, message formatting, marker @@ -48,7 +48,7 @@ * * The full rule, with reference examples from the platform/xunit codebases (e.g. * `service ConsoleExecutionListener`, `service ErrorLog`), is in - * `doc/logging/design.md` under "Sink type: `const` vs `service`". + * `doc/logging/design/design.md` under "Sink type: `const` vs `service`". */ interface LogSink { diff --git a/lib_logging/src/main/x/logging/Marker.x b/lib_logging/src/main/x/logging/Marker.x index 8111c13b43..b0686d11ca 100644 --- a/lib_logging/src/main/x/logging/Marker.x +++ b/lib_logging/src/main/x/logging/Marker.x @@ -29,7 +29,7 @@ * The default implementation in this module ([BasicMarker]) is intentionally minimal — the * in-memory marker shipped here exists primarily so that user code that *uses* markers * compiles and runs against the default sink (which mostly ignores them). Real filtering is - * the job of richer sinks (see `doc/logging/logback-integration.md`). + * the job of richer sinks (see `doc/logging/future/logback-integration.md`). */ interface Marker extends Freezable diff --git a/lib_logging/src/main/x/logging/MemoryLogSink.x b/lib_logging/src/main/x/logging/MemoryLogSink.x index 0dca7848e8..fd4e023ccc 100644 --- a/lib_logging/src/main/x/logging/MemoryLogSink.x +++ b/lib_logging/src/main/x/logging/MemoryLogSink.x @@ -18,7 +18,7 @@ * the assertion code that reads back the events). That is structurally identical to * `service ConsoleExecutionListener` in `lib_xunit_engine` and `service ErrorLog` in * `platform/common` — both are stateful event collectors. See - * `doc/logging/design.md` ("Sink type: `const` vs `service`") for the full rule. + * `doc/logging/design/design.md` ("Sink type: `const` vs `service`") for the full rule. */ service MemoryLogSink implements LogSink { diff --git a/lib_logging/src/main/x/logging/NoopLogSink.x b/lib_logging/src/main/x/logging/NoopLogSink.x index 838230e43d..2dc72cac72 100644 --- a/lib_logging/src/main/x/logging/NoopLogSink.x +++ b/lib_logging/src/main/x/logging/NoopLogSink.x @@ -15,7 +15,7 @@ * `NoopLogSink` is purely stateless — `isEnabled` always returns `False` and `log` is * intentionally empty. There is nothing to mutate, nothing to share, nothing to fan in * from many fibers. That is the canonical case for `const`: cheap to construct, cheap - * to pass across service boundaries, no scheduler overhead. See `doc/logging/design.md` + * to pass across service boundaries, no scheduler overhead. See `doc/logging/design/design.md` * ("Sink type: `const` vs `service`") for the full rule. */ const NoopLogSink diff --git a/lib_logging/src/test/x/LoggingTest/CountingSink.x b/lib_logging/src/test/x/LoggingTest/CountingSink.x index df2b8ec61b..adc0d3844f 100644 --- a/lib_logging/src/test/x/LoggingTest/CountingSink.x +++ b/lib_logging/src/test/x/LoggingTest/CountingSink.x @@ -5,7 +5,7 @@ import logging.Marker; /** * Test-only sink: counts events by level. Useful as a reference example for users - * writing their own custom sinks — see `doc/logging/custom-sinks.md`. + * writing their own custom sinks — see `doc/logging/usage/custom-sinks.md`. */ service CountingSink implements LogSink { diff --git a/lib_logging/src/test/x/LoggingTest/ListLogSink.x b/lib_logging/src/test/x/LoggingTest/ListLogSink.x index 10b9943073..90738d9645 100644 --- a/lib_logging/src/test/x/LoggingTest/ListLogSink.x +++ b/lib_logging/src/test/x/LoggingTest/ListLogSink.x @@ -11,7 +11,7 @@ import logging.Marker; * `rootLevel` defaults to `Trace` so by default every emitted event is captured. * * Modelled as a `service` because it carries mutable state (`events[]`) shared across - * the fiber-under-test and the assertion code. See `doc/logging/design.md` + * the fiber-under-test and the assertion code. See `doc/logging/design/design.md` * ("Sink type: `const` vs `service`"). */ service ListLogSink diff --git a/lib_slogging/src/main/x/slogging/MemoryHandler.x b/lib_slogging/src/main/x/slogging/MemoryHandler.x index 29d2d6ef80..da561ee8dd 100644 --- a/lib_slogging/src/main/x/slogging/MemoryHandler.x +++ b/lib_slogging/src/main/x/slogging/MemoryHandler.x @@ -14,7 +14,7 @@ * It accumulates records. The backing array is mutated on every `handle()` call and * the same instance is shared across the logger-under-test fiber and the assertion * fiber. Same reasoning as `lib_logging`'s `MemoryLogSink`. See - * `doc/logging/design.md` ("Sink type: `const` vs `service`"). + * `doc/logging/design/design.md` ("Sink type: `const` vs `service`"). */ service MemoryHandler implements Handler { diff --git a/lib_slogging/src/main/x/slogging/NopHandler.x b/lib_slogging/src/main/x/slogging/NopHandler.x index 19cff516d5..cb74d52376 100644 --- a/lib_slogging/src/main/x/slogging/NopHandler.x +++ b/lib_slogging/src/main/x/slogging/NopHandler.x @@ -9,7 +9,7 @@ * # Why this handler is a `const` * * Identical reasoning to `lib_logging`'s `NoopLogSink`: no state, no shared mutation, - * pure forwarder. See `doc/logging/design.md` ("Sink type: `const` vs `service`"). + * pure forwarder. See `doc/logging/design/design.md` ("Sink type: `const` vs `service`"). */ const NopHandler implements Handler { diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index 59e7b0c948..d0c678134b 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -21,7 +21,7 @@ * * Same rule as `lib_logging`'s `ConsoleLogSink`: stateless forwarder over `@Inject * Console`. Threshold fixed at construction. No accumulation, no shared mutable state. - * See `doc/logging/design.md` ("Sink type: `const` vs `service`") for the full rule. + * See `doc/logging/design/design.md` ("Sink type: `const` vs `service`") for the full rule. */ const TextHandler(Level rootLevel, String groupPrefix) implements Handler { diff --git a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x index 9b5a96c6bf..be8e80929d 100644 --- a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x @@ -12,7 +12,7 @@ import slogging.Record; * * Modelled as a `service` because it carries mutable state (the records list) shared * across the fiber-under-test and the assertion code. See - * `doc/logging/design.md` ("Sink type: `const` vs `service`"). + * `doc/logging/design/design.md` ("Sink type: `const` vs `service`"). */ service ListHandler implements Handler { diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x index db1638a1e6..29c59688cc 100644 --- a/manualTests/src/main/x/TestLogger.x +++ b/manualTests/src/main/x/TestLogger.x @@ -92,7 +92,7 @@ module TestLogger { * class). The derived logger shares this logger's sink, so all configuration applied * to the default logger flows through to its descendants. * - * Bare-essentials demo target (`doc/logging/runtime-implementation-plan.md`): the + * Bare-essentials demo target (`doc/logging/future/runtime-implementation-plan.md`): the * message must print as `hello world` (formatted by `MessageFormatter`), not * `hello {}` (raw). The logger-name column on the resulting line should read `Demo`. */ From f3fb55202e6ae4a858785cccddc5a5ff47951af2 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Mon, 4 May 2026 23:07:16 +0200 Subject: [PATCH 09/28] Bring slogging POC to logging parity Add runtime injection, structured JSON handling, handler derivation, context support, tests, and cross-reference docs for the logging and slogging comparison. --- doc/logging/README.md | 61 ++++-- doc/logging/api-cross-reference.md | 99 ++++++++++ doc/logging/design/design.md | 4 +- doc/logging/design/xdk-alignment.md | 25 ++- doc/logging/future/lazy-logging.md | 2 +- doc/logging/future/native-bridge.md | 7 +- .../future/runtime-implementation-plan.md | 10 +- doc/logging/lib-logging-vs-lib-slogging.md | 91 +++++---- doc/logging/open-questions.md | 29 ++- doc/logging/usage/custom-handlers.md | 176 ++++++++++++++++++ doc/logging/usage/custom-sinks.md | 21 ++- doc/logging/usage/ecstasy-vs-java-examples.md | 27 +-- .../usage/platform-and-examples-adaptation.md | 18 +- doc/logging/usage/slf4j-parity.md | 11 +- doc/logging/usage/slog-parity.md | 155 +++++++++++++++ doc/logging/usage/structured-logging.md | 37 ++-- gradle/libs.versions.toml | 1 + .../java/org/xvm/runtime/NativeContainer.java | 76 +++++--- lib_logging/README.md | 15 +- lib_logging/src/main/x/logging.x | 10 +- .../src/main/x/logging/BasicEventBuilder.x | 33 ++++ lib_logging/src/main/x/logging/BasicMarker.x | 18 ++ .../src/main/x/logging/ConsoleLogSink.x | 8 + lib_logging/src/main/x/logging/Logger.x | 56 +++++- .../src/main/x/logging/LoggerFactory.x | 9 + .../src/main/x/logging/LoggerRegistry.x | 10 +- .../src/main/x/logging/LoggingEventBuilder.x | 13 +- .../src/main/x/logging/MarkerFactory.x | 8 +- lib_logging/src/test/x/LoggingTest.x | 2 +- .../test/x/LoggingTest/MarkerFactoryTest.x | 43 +++++ lib_slogging/README.md | 51 +++++ lib_slogging/build.gradle.kts | 1 + lib_slogging/src/main/x/slogging.x | 21 ++- lib_slogging/src/main/x/slogging/Attr.x | 6 +- .../src/main/x/slogging/BoundHandler.x | 100 ++++++++++ lib_slogging/src/main/x/slogging/Handler.x | 15 +- .../src/main/x/slogging/JSONHandler.x | 169 ++++++++++++++--- lib_slogging/src/main/x/slogging/Level.x | 6 + lib_slogging/src/main/x/slogging/Logger.x | 124 +++++++++--- .../src/main/x/slogging/LoggerContext.x | 53 ++++++ .../src/main/x/slogging/MemoryHandler.x | 23 ++- lib_slogging/src/main/x/slogging/NopHandler.x | 28 ++- lib_slogging/src/main/x/slogging/Record.x | 17 +- .../src/main/x/slogging/TextHandler.x | 29 +-- lib_slogging/src/test/x/SLoggingTest.x | 5 + .../src/test/x/SLoggingTest/CountingHandler.x | 38 ++++ .../test/x/SLoggingTest/CountingHandlerTest.x | 24 +++ .../src/test/x/SLoggingTest/HandlerContract.x | 60 ++++++ .../test/x/SLoggingTest/HandlerContractTest.x | 19 ++ .../x/SLoggingTest/HandlerDerivationTest.x | 39 ++++ .../src/test/x/SLoggingTest/JSONHandlerTest.x | 75 ++++++++ .../src/test/x/SLoggingTest/ListHandler.x | 8 +- .../test/x/SLoggingTest/LoggerContextTest.x | 40 ++++ .../src/test/x/SLoggingTest/NopHandlerTest.x | 19 ++ .../test/x/SLoggingTest/SourceLocationTest.x | 34 ++++ .../src/test/x/SLoggingTest/TrackingHandler.x | 47 +++++ .../src/test/x/SLoggingTest/WithGroupTest.x | 35 +++- manualTests/src/main/x/TestLogger.x | 29 ++- xdk/build.gradle.kts | 1 + 59 files changed, 1906 insertions(+), 285 deletions(-) create mode 100644 doc/logging/api-cross-reference.md create mode 100644 doc/logging/usage/custom-handlers.md create mode 100644 doc/logging/usage/slog-parity.md create mode 100644 lib_logging/src/test/x/LoggingTest/MarkerFactoryTest.x create mode 100644 lib_slogging/README.md create mode 100644 lib_slogging/src/main/x/slogging/BoundHandler.x create mode 100644 lib_slogging/src/main/x/slogging/LoggerContext.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/CountingHandler.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/CountingHandlerTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/HandlerContract.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/NopHandlerTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x diff --git a/doc/logging/README.md b/doc/logging/README.md index 87d425016f..c5a9796fbd 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -5,10 +5,23 @@ XDK. They are an experiment, not a final shipping decision: we built both so reviewers can compare them side-by-side and tell us which API shape Ecstasy should adopt long-term. +The "why" is simple: Ecstasy should not invent an unfamiliar logging style when +the rest of the industry has already converged on two recognizable ones. The +branch proves both shapes in real XDK code: + +- **API/implementation split.** User code depends on `Logger`; routing lives behind + `LogSink` or `Handler`. +- **Injection-first acquisition.** The host container controls logging, the same way + it already controls `Console`. +- **Small default, open extension point.** The base library gives a console/no-op/ + memory implementation; production behavior is just another sink or handler. +- **Structured data.** Both designs can carry machine-readable fields to JSON/cloud + sinks without regexing messages. + - **[`lib_logging`](../../lib_logging/)** — modelled on **SLF4J 2.x + Logback**. - Familiar to anyone with JVM background. 51 unit tests, end-to-end demo. + Familiar to anyone with JVM background. 54 unit tests, end-to-end demo. - **[`lib_slogging`](../../lib_slogging/)** — modelled on **Go's `log/slog`** - (the modern cloud-native idiom). 22 unit tests, parallel design. + (the modern cloud-native idiom). 34 unit tests, parallel design. If you only have time to read one document, read **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — it is @@ -24,6 +37,10 @@ and **seven language-design questions** (Q-D1..Q-D7) in has skimmed the headline comparison; you do not need to read the entire doc tree to weigh in. +The highest-value review question is: **which single API should Ecstasy make +canonical?** Keeping both forever would recreate the logging-fragmentation problem +these APIs were designed to avoid. + ## Reading paths Pick the one that matches what you are. @@ -33,11 +50,13 @@ Pick the one that matches what you are. 1. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — the side-by-side comparison, deep dive on markers, eleven reviewer questions, tentative recommendation. -2. **[`cloud-integration.md`](cloud-integration.md)** — why the API choice is +2. **[`api-cross-reference.md`](api-cross-reference.md)** — maps every POC type to the + official SLF4J or Go `log/slog` API it mirrors, with the Ecstasy-specific differences. +3. **[`cloud-integration.md`](cloud-integration.md)** — why the API choice is the entry point to GCP / AWS / Azure observability ecosystems. Explains the modern deployment world for readers whose intuition is "ship a CLI installer." -3. **[`open-questions.md`](open-questions.md)** — questions for the XTC +4. **[`open-questions.md`](open-questions.md)** — questions for the XTC language designers (Q-D1..Q-D7), tracking list of work not yet implemented, implementation tiers. @@ -48,17 +67,23 @@ support material. 1. **[`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md)** — every SLF4J idiom with the equivalent Ecstasy code beside it. -2. **[`usage/slf4j-parity.md`](usage/slf4j-parity.md)** — exhaustive type-by-type and +2. **[`api-cross-reference.md`](api-cross-reference.md)** — official SLF4J links beside the + local Ecstasy source files and difference notes. +3. **[`usage/slf4j-parity.md`](usage/slf4j-parity.md)** — exhaustive type-by-type and method-by-method mapping. Reference, not narrative. -3. **[`usage/injected-logger-example.md`](usage/injected-logger-example.md)** — a complete +4. **[`usage/injected-logger-example.md`](usage/injected-logger-example.md)** — a complete end-to-end example of `@Inject Logger logger;` with a runnable companion at `manualTests/src/main/x/TestLogger.x`. ### I'm a Go / slog engineer — show me what changes -1. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — has +1. **[`api-cross-reference.md`](api-cross-reference.md)** — official Go `log/slog` links beside + the local Ecstasy source files and difference notes. +2. **[`usage/slog-parity.md`](usage/slog-parity.md)** — method-by-method mapping, + including `LoggerContext`, `logAt`, and handler derivation. +3. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — has side-by-side slog → Ecstasy code throughout. -2. **The `lib_slogging` source** at +4. **The `lib_slogging` source** at [`lib_slogging/src/main/x/slogging/`](../../lib_slogging/src/main/x/slogging/) — the API surface is small (Logger, Handler, Record, Attr, Level) and each file has a doc-comment naming its slog counterpart. @@ -76,7 +101,9 @@ support material. 1. **[`usage/custom-sinks.md`](usage/custom-sinks.md)** — guide to writing a custom `LogSink`, with worked examples (counting, file, hierarchical, tee, marker-filtering, JSON-line). Includes the `const` vs `service` decision. -2. **[`usage/structured-logging.md`](usage/structured-logging.md)** — how SLF4J 2.x +2. **[`usage/custom-handlers.md`](usage/custom-handlers.md)** — guide to writing a custom + slog `Handler`, including derivation hooks and attr-based filtering. +3. **[`usage/structured-logging.md`](usage/structured-logging.md)** — how SLF4J 2.x structured logging maps onto `lib_logging`, with a sketch of a JSON sink. ### I want to deploy this to GCP / AWS / Azure @@ -102,6 +129,7 @@ support material. | **Top-level** | | | [`README.md`](README.md) | This file. The "start here" entry point. | | [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | Headline comparison + reviewer questions. **Read first.** | +| [`api-cross-reference.md`](api-cross-reference.md) | Official SLF4J / Go slog API links mapped to local Ecstasy types and difference notes. | | [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | | [`open-questions.md`](open-questions.md) | Live tracking: open questions, designer questions (Q-D1..Q-D7), W-item parity list, implementation tiers. | | **Design (`lib_logging` side)** | | @@ -112,9 +140,11 @@ support material. | [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | End-to-end working example of `@Inject Logger`. | | [`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | | [`usage/slf4j-parity.md`](usage/slf4j-parity.md) | Exhaustive type/method mapping reference. | +| [`usage/slog-parity.md`](usage/slog-parity.md) | Go `log/slog` vs `lib_slogging` mapping reference. | | [`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md) | Migration survey for existing Ecstasy code bases. | | **Extension** | | | [`usage/custom-sinks.md`](usage/custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | +| [`usage/custom-handlers.md`](usage/custom-handlers.md) | How to write a custom slog `Handler`, with worked examples. | | [`usage/structured-logging.md`](usage/structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | | **Tier 3 / future work** | | | [`future/logback-integration.md`](future/logback-integration.md) | Sketch of a configuration-driven Logback-style binding. | @@ -149,7 +179,7 @@ lib_logging/ SLF4J-shaped library │ ├── ConsoleLogSink.x default sink (const), forwards to @Inject Console │ ├── NoopLogSink.x drops every event (const) │ └── MemoryLogSink.x test-helper, captures events (service) - └── test/x/LoggingTest/ 51 unit tests + └── test/x/LoggingTest/ 54 unit tests lib_slogging/ slog-shaped sibling library ├── build.gradle.kts @@ -161,18 +191,21 @@ lib_slogging/ slog-shaped sibling library │ ├── Attr.x the single structured-data carrier │ ├── Record.x immutable event const (the LogEvent equivalent) │ ├── Handler.x SPI (the LogSink equivalent) + │ ├── BoundHandler.x default with/withGroup derivation wrapper │ ├── Logger.x concrete user-facing const + │ ├── LoggerContext.x optional SharedContext helper │ ├── TextHandler.x default human-readable handler (const) - │ ├── JSONHandler.x JSON-Lines structured handler (const, skeleton) + │ ├── JSONHandler.x JSON-Lines handler (const, lib_json renderer) │ ├── NopHandler.x drops every record (const) │ └── MemoryHandler.x test-helper (service) - └── test/x/SLoggingTest/ 22 unit tests + └── test/x/SLoggingTest/ 34 unit tests ``` ## Status -**Both libraries compile and pass tests.** End-to-end demo runs in -`manualTests/TestLogger.x` (interpreter side; JIT-side wiring is Tier 3). +**Both libraries are intended to compile and pass tests.** End-to-end demo runs in +`manualTests/TestLogger.x` and exercises both injected logger types (interpreter side; +JIT-side wiring is Tier 3). Tier 1+2 work landed in this branch; Tier 3 (compiler default-name, `AsyncLogSink`, `lib_logging_logback`, native bridge) is explicitly out of scope for this PR — see `open-questions.md` for the tier breakdown. The diff --git a/doc/logging/api-cross-reference.md b/doc/logging/api-cross-reference.md new file mode 100644 index 0000000000..ebc2cde8dd --- /dev/null +++ b/doc/logging/api-cross-reference.md @@ -0,0 +1,99 @@ +# Logging API cross-reference + +This page maps the Ecstasy POC types to the well-known APIs they intentionally +mirror. Use it when reading the source: the point is not "we copied Java" or "we +copied Go", but "we reused the smallest familiar shape, then adapted acquisition, +services, consts, and fiber context to Ecstasy." + +Primary references: + +- SLF4J API docs: [`org.slf4j.Logger`](https://www.slf4j.org/api/org/slf4j/Logger.html), + [`LoggerFactory`](https://www.slf4j.org/api/org/slf4j/LoggerFactory.html), + [`Marker`](https://www.slf4j.org/api/org/slf4j/Marker.html), + [`MarkerFactory`](https://www.slf4j.org/api/org/slf4j/MarkerFactory.html), + [`MDC`](https://www.slf4j.org/api/org/slf4j/MDC.html), + [`LoggingEventBuilder`](https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html), + [`MessageFormatter`](https://www.slf4j.org/api/org/slf4j/helpers/MessageFormatter.html), + [`Level`](https://www.slf4j.org/api/org/slf4j/event/Level.html), + [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html). +- Go `log/slog` package docs: [`log/slog`](https://pkg.go.dev/log/slog). + +## SLF4J-shaped `lib_logging` + +| Ecstasy implementation | Prior-art API | Same idea | Ecstasy difference | +|---|---|---|---| +| [`Logger`](../../lib_logging/src/main/x/logging/Logger.x) | SLF4J [`Logger`](https://www.slf4j.org/api/org/slf4j/Logger.html) | Per-level methods, level checks, parameterized `{}` messages, markers, fluent `atInfo()` family. | Ecstasy collapses SLF4J's many overloads into default arguments. Acquisition is `@Inject Logger logger;` plus `logger.named("full.name")`, not a static classpath binding. | +| [`BasicLogger`](../../lib_logging/src/main/x/logging/BasicLogger.x) | SLF4J binding logger implementations such as `SimpleLogger` / Logback logger | One canonical implementation funnels all call shapes through a single emission path. | It is a `const` over a `LogSink`, so calls run on the caller's fiber and can see `MDC` `SharedContext` state. | +| [`LoggerFactory`](../../lib_logging/src/main/x/logging/LoggerFactory.x), [`LoggerRegistry`](../../lib_logging/src/main/x/logging/LoggerRegistry.x) | SLF4J [`LoggerFactory`](https://www.slf4j.org/api/org/slf4j/LoggerFactory.html) / `ILoggerFactory` | Name-keyed logger acquisition and identity-stable lookup. | The factory is an injected service escape hatch, not the primary acquisition path. The registry is scoped to one sink/container instead of JVM-global classpath state. | +| [`Level`](../../lib_logging/src/main/x/logging/Level.x) | SLF4J [`org.slf4j.event.Level`](https://www.slf4j.org/api/org/slf4j/event/Level.html) | `Trace`, `Debug`, `Info`, `Warn`, `Error`. | Adds sink-side `Off` and exposes integer `severity` for cheap threshold checks. | +| [`Marker`](../../lib_logging/src/main/x/logging/Marker.x), [`BasicMarker`](../../lib_logging/src/main/x/logging/BasicMarker.x), [`MarkerFactory`](../../lib_logging/src/main/x/logging/MarkerFactory.x) | SLF4J [`Marker`](https://www.slf4j.org/api/org/slf4j/Marker.html), [`MarkerFactory`](https://www.slf4j.org/api/org/slf4j/MarkerFactory.html) | Named event tags, child-marker containment, detached vs interned markers, multiple markers through the fluent API. | `Marker` also extends XDK `Freezable`, `Stringable`, and `Hashable` so markers can ride on `const LogEvent`s and cross service boundaries. | +| [`MDC`](../../lib_logging/src/main/x/logging/MDC.x) | SLF4J [`MDC`](https://www.slf4j.org/api/org/slf4j/MDC.html) | Request-scoped string map captured on each event. | Acquired by `@Inject MDC mdc;` and backed by `SharedContext` for per-fiber semantics instead of Java static thread-local access. | +| [`MessageFormatter`](../../lib_logging/src/main/x/logging/MessageFormatter.x) | SLF4J [`MessageFormatter`](https://www.slf4j.org/api/org/slf4j/helpers/MessageFormatter.html) | `{}` substitution, escape handling, and trailing-exception promotion. | Returns `(String, Exception?)`; an explicit `cause=` on the logger call wins over a promoted trailing exception. | +| [`LoggingEventBuilder`](../../lib_logging/src/main/x/logging/LoggingEventBuilder.x), [`BasicEventBuilder`](../../lib_logging/src/main/x/logging/BasicEventBuilder.x) | SLF4J [`LoggingEventBuilder`](https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html) | Fluent `setMessage`, `addArgument`, `addMarker`, `setCause`, `addKeyValue`, `log`. | Supplier/lazy overloads are not implemented yet. The level check runs at `log(...)` so marker-aware sinks can participate. | +| [`LogEvent`](../../lib_logging/src/main/x/logging/LogEvent.x) | SLF4J [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html), Logback `ILoggingEvent` | Immutable event payload carrying logger name, level, message, markers, cause, arguments, MDC, and key/value pairs. | Ecstasy uses a `const`, with `Marker[] markers` plus `marker` convenience accessor and a `Map` for structured KV pairs. | +| [`LogSink`](../../lib_logging/src/main/x/logging/LogSink.x) | SLF4J provider boundary / Logback appender concept | Backend SPI: cheap enabled check plus event emission. | One interface allows both `const` forwarders and `service` stateful sinks; the `const`/`service` choice is documented in [`design/design.md`](design/design.md). | +| [`ConsoleLogSink`](../../lib_logging/src/main/x/logging/ConsoleLogSink.x), [`NoopLogSink`](../../lib_logging/src/main/x/logging/NoopLogSink.x), [`MemoryLogSink`](../../lib_logging/src/main/x/logging/MemoryLogSink.x) | `slf4j-simple`, `slf4j-nop`, Logback `ListAppender`-style test capture | Minimal default output, silent output, and test capture. | Shipped as tiny Ecstasy sinks so the base library is useful without a Logback-style backend. | + +## slog-shaped `lib_slogging` + +| Ecstasy implementation | Prior-art API | Same idea | Ecstasy difference | +|---|---|---|---| +| [`Logger`](../../lib_slogging/src/main/x/slogging/Logger.x) | Go [`slog.Logger`](https://pkg.go.dev/log/slog#Logger) | A logger holds a handler; per-level methods create records and forward them. `with` / `withGroup` derive handlers. | Ecstasy `Logger` is a `const` and is injectable via `@Inject slogging.Logger logger;`. It omits Go's `context.Context` parameter from per-call APIs. | +| [`LoggerContext`](../../lib_slogging/src/main/x/slogging/LoggerContext.x) | Go [`context.Context`](https://pkg.go.dev/context) as used by `log/slog` | Optional request-scoped propagation for the active logger. | Uses Ecstasy `SharedContext` instead of threading `context.Context` through every logging call. Explicit logger passing remains the default recommendation. | +| [`Handler`](../../lib_slogging/src/main/x/slogging/Handler.x), [`BoundHandler`](../../lib_slogging/src/main/x/slogging/BoundHandler.x) | Go [`slog.Handler`](https://pkg.go.dev/log/slog#Handler) | `enabled`, `handle`, `withAttrs`, `withGroup`; derivation lets handlers pre-resolve context. | Ecstasy drops the `context.Context` parameter and `error` return for now; `BoundHandler` supplies the default derivation semantics for simple handlers. | +| [`Record`](../../lib_slogging/src/main/x/slogging/Record.x) | Go [`slog.Record`](https://pkg.go.dev/log/slog#Record) | One immutable record carries time, level, message, source, and attrs. | Adds an explicit `Exception?` slot because Ecstasy exceptions are first-class and tests can assert on them directly. | +| [`Attr`](../../lib_slogging/src/main/x/slogging/Attr.x) | Go [`slog.Attr`](https://pkg.go.dev/log/slog#Attr), [`slog.Group`](https://pkg.go.dev/log/slog#Group) | All structured data is key/value attrs; groups are nested attrs. | Uses `Object` values instead of Go's `slog.Value` kind union. Handlers inspect value types directly. | +| [`Level`](../../lib_slogging/src/main/x/slogging/Level.x) | Go [`slog.Level`](https://pkg.go.dev/log/slog#Level) | Open integer severity line with canonical `Debug`, `Info`, `Warn`, `Error`. | Stores a display `label` alongside severity for simple rendering. | +| [`TextHandler`](../../lib_slogging/src/main/x/slogging/TextHandler.x) | Go [`TextHandler`](https://pkg.go.dev/log/slog#TextHandler) | Human-readable `key=value` output and group prefixing. | Writes through `@Inject Console`; escaping/options are intentionally minimal in this POC. | +| [`JSONHandler`](../../lib_slogging/src/main/x/slogging/JSONHandler.x) | Go [`JSONHandler`](https://pkg.go.dev/log/slog#JSONHandler) | One line-delimited JSON object per record, nested groups, structured exceptions. | Renders through XDK `lib_json` and writes through injected `Console`; handler options/output streams are future backend work. | +| [`MemoryHandler`](../../lib_slogging/src/main/x/slogging/MemoryHandler.x), [`NopHandler`](../../lib_slogging/src/main/x/slogging/NopHandler.x) | Go test/discard handler patterns | Capture records for assertions or drop records entirely. | Small Ecstasy convenience handlers; not direct Go stdlib types. | + +## No slog counterpart for `MessageFormatter` + +`lib_logging.MessageFormatter` intentionally has no `lib_slogging` equivalent. SLF4J +uses a templated message plus positional arguments: + +```ecstasy +logger.info("processed {} records", [count]); +``` + +Go `log/slog` uses a completed message plus structured attrs: + +```ecstasy +logger.info("processed records", [Attr.of("count", count)]); +``` + +Reusing `MessageFormatter` inside `lib_slogging` would be architecturally wrong for +the core API because it would add a second structured-data channel (positional +arguments) to the design whose main advantage is having exactly one channel (`Attr`). +If migration sugar is useful later, it should live in an adapter/helper module that +makes the compromise explicit, for example `lib_slogging_slf4j_adapter`. + +## Call-shape comparison + +| Intent | Java / Go prior art | Ecstasy POC | +|---|---|---| +| Named logger | `LoggerFactory.getLogger(PaymentService.class)` | `@Inject Logger root; Logger log = root.named("PaymentService");` | +| SLF4J-style formatted message | `log.info("processed {}", count)` | `log.info("processed {}", [count])` | +| SLF4J-style structured event | `log.atInfo().addKeyValue("id", id).log("processed")` | `log.atInfo().addKeyValue("id", id).log("processed")` | +| Request context with MDC | `MDC.put("requestId", id)` | `@Inject MDC mdc; mdc.put("requestId", id);` | +| slog-style derived logger | `logger.With("requestId", id)` | `Logger reqLog = logger.with([Attr.of("requestId", id)]);` | +| slog-style context logger | `logger.LogAttrs(ctx, level, msg, attrs...)` | `using (loggerContext.bind(reqLog)) { ... }` plus `loggerContext.currentOr(root)` | +| slog-style structured event | `logger.Info("processed", "count", count)` | `logger.info("processed", [Attr.of("count", count)]);` | + +## What to keep in mind while reviewing + +- `lib_logging` optimizes for **JVM familiarity and Logback-style operational + extension**. It keeps separate concepts for message args, markers, MDC, and + structured key/value pairs because SLF4J users already know those concepts. +- `lib_slogging` optimizes for **conceptual economy**. Everything structured is an + `Attr`; categories and request context are not special. +- Both designs put real deployment behavior behind one extension point: + `LogSink` for the SLF4J shape, `Handler` for the slog shape. +- The biggest Ecstasy-specific change in both designs is acquisition: the long-term + shape should be container-controlled injection, not global static configuration. + + +--- + +_See also [README.md](README.md) for the full doc index and reading paths._ diff --git a/doc/logging/design/design.md b/doc/logging/design/design.md index f0a9151d76..3190d7bee4 100644 --- a/doc/logging/design/design.md +++ b/doc/logging/design/design.md @@ -246,7 +246,7 @@ helper lives in the host runtime's injector library, not here. ## Non-goals (v0) -Items deliberately *not* in scope for the v0 stub of `lib_logging`. Each is +Items deliberately *not* in scope for the base POC of `lib_logging`. Each is either tracked as Tier 3 in `../open-questions.md` or punted on principle. - **Distributed tracing context propagation.** `MDC` carries strings; that's @@ -285,7 +285,7 @@ The runtime-side injection wiring lives in earlier interpose service `xRTLogger.java` was removed in favour of constructing `BasicLogger` directly so MDC fiber-locals survive injection (see Q-D5 in `../open-questions.md`). The real `MessageFormatter` is implemented (12 tests in -`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (51 +`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (54 passing as of this commit). diff --git a/doc/logging/design/xdk-alignment.md b/doc/logging/design/xdk-alignment.md index 0434233391..797d81bdf6 100644 --- a/doc/logging/design/xdk-alignment.md +++ b/doc/logging/design/xdk-alignment.md @@ -42,8 +42,10 @@ Where there is more than one of something, the resource name disambiguates: @Inject("homeDir") Directory home; // from BasicResourceProvider ``` -`lib_logging` does the same: `@Inject Logger logger;` for the default, optional -`@Inject("com.example.foo") Logger logger;` for a named one. +`lib_logging` deliberately does **not** use named injection for logger names. It uses +`@Inject Logger logger;` for the root logger, then derives full-name loggers by API: +`Logger payments = logger.named("com.example.payments");`. This keeps the injector's +resource table exact-match and avoids a one-off wildcard injection rule for logging. ### 3. Defaults via the `Inject` annotation's `resourceName` @@ -51,8 +53,10 @@ Where there is more than one of something, the resource name disambiguates: `Inject(String? resourceName = Null, Options opts = Null)`. The `resourceName` is the dispatch key; null means "default for this type". Every XDK lib follows this. -`lib_logging` follows it too: `@Inject Logger` resolves the default, `@Inject("foo") -Logger` names the logger. Same shape as `Directory`. +`lib_logging` follows the default-resource part (`@Inject Logger logger;`) but not +the named-resource part for logger names. Directory names identify distinct host +resources; logger names are application categories. Those categories belong in the +logging API (`logger.named(...)`), not in the runtime injector. ### 4. The four canonical interfaces for shareable values @@ -132,11 +136,16 @@ the right way; they just lack a `Logger` to round out the injection surface. | Convention | `lib_logging` | |---|---| -| Injectable interface type | `Logger`, `LogSink`, `MarkerFactory`, `MDC`, `LoggerFactory` — all interfaces or services with `@Inject`-able shape. | -| Named injection for named instances | `@Inject("com.example") Logger logger;` | +| Injectable interface type | `Logger` and `MDC` are wired by the runtime in this branch. `LogSink`, `MarkerFactory`, and `LoggerFactory` are designed as injectable extension points but not yet registered by the interpreter's native container. | +| Named logger acquisition | `@Inject Logger logger; Logger named = logger.named("com.example");` | | `Const` for immutable records | `LogEvent` | -| `service` for stateful actors | `ConsoleLogSink`, `MarkerFactory`, `MDC`, `LoggerFactory` | -| `class` for value-but-mutable | `BasicLogger`, `BasicEventBuilder`, `BasicMarker`, `MemoryLogSink`, `NoopLogSink` | +| `const` for stateless/passable values | `BasicLogger`, `ConsoleLogSink`, `NoopLogSink`, `MDC`, `LogEvent` | +| `service` for stateful actors | `MarkerFactory`, `LoggerFactory`, `LoggerRegistry`, `MemoryLogSink` | +| `class` for mutable helpers/builders | `BasicEventBuilder`, `BasicMarker` | + +The slog-shaped sibling follows the same injection convention: `slogging.Logger` is +also registered under the resource name `logger`, and the native injector disambiguates +by requested type. | Module file layout | `lib_logging/src/main/x/logging.x` declaring `module logging.xtclang.org` | | Subordinate types under same dir | `lib_logging/src/main/x/logging/.x` | | Build script shape | matches `lib_cli` / `lib_json` exactly | diff --git a/doc/logging/future/lazy-logging.md b/doc/logging/future/lazy-logging.md index 322082e114..3fee625d11 100644 --- a/doc/logging/future/lazy-logging.md +++ b/doc/logging/future/lazy-logging.md @@ -106,7 +106,7 @@ void info(function String () messageFn, **Pros** -- Zero language work — implementable today against the existing `lib_logging` stub. +- Zero language work — implementable today against the existing `lib_logging` POC. - One-line change at every call site that wants laziness. - Plays nicely with markers, MDC, and the fluent builder. - Works the same way `slf4j-api`'s newer fluent `log(Supplier)` works, so the diff --git a/doc/logging/future/native-bridge.md b/doc/logging/future/native-bridge.md index c1bd5f8de6..6871be27ca 100644 --- a/doc/logging/future/native-bridge.md +++ b/doc/logging/future/native-bridge.md @@ -83,12 +83,13 @@ suppliers.put(new Resource(consoleType, "console"), TerminalConsole::$create); For `lib_logging`, we'd add: ```java -suppliers.put(new Resource(loggerType, "*"), RTLogger::$create); +suppliers.put(new Resource(loggerType, "logger"), RTLogger::$create); suppliers.put(new Resource(logSinkType, "default"), RTLogbackSink::$create); ``` -The wildcard `"*"` for `Logger` is the workaround for the current exact-name resolution -(see `../open-questions.md` — this is a small change to `nMainInjector.supplierOf`). +The interpreter-side `NativeContainer` now resolves by `(resource name, requested type)`; +the JIT bridge should follow the same rule if this experiment grows into a JIT-backed +logging bridge. The suppliers map is per-Injector instance, so each container can choose its own sink. There is no JVM-global state; this matches Ecstasy's container isolation diff --git a/doc/logging/future/runtime-implementation-plan.md b/doc/logging/future/runtime-implementation-plan.md index 281cc12bb0..6f7f82a82a 100644 --- a/doc/logging/future/runtime-implementation-plan.md +++ b/doc/logging/future/runtime-implementation-plan.md @@ -13,8 +13,8 @@ > as instructions for current work. Stage 4 (compiler-side default name) > remains open. -This document is the actionable plan that turns the v0 stub (`@Inject Logger logger;` -parses but does not resolve) into a **working end-to-end demo**: the Ecstasy line +This historical plan described how to turn the original v0 stub (`@Inject Logger logger;` +parsed but did not resolve) into a **working end-to-end demo**: the Ecstasy line ```ecstasy @Inject Logger logger; @@ -60,9 +60,9 @@ The round trip is demo-worthy when **all** of these are true: by a sink that cares; default `ConsoleLogSink` would gain a small change to render MDC entries). -6. The 18 unit tests still pass; one new manualTest invokes `runInjected()` and - succeeds (today the `runInjected()` path catches the unresolved-injection - exception). +6. The focused `lib_logging` suite (54 XTC test methods) still compiles cleanly; + `manualTests/src/main/x/TestLogger.x` invokes both `runInjected()` and the + slog-shaped `runInjectedSlog()` path successfully. 7. `~/src/platform/kernel/kernel.x` compiles unchanged (it does not use `@Inject Logger` yet) AND a small follow-up PR converting one of its diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index 8fb90b25ca..cb05e6533c 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -27,11 +27,14 @@ This document: 4. lists the reviewer questions that motivate the experiment; 5. records a tentative recommendation, knowing it may shift after review. -> Status note: `lib_slogging` is currently a skeleton (interfaces + a TextHandler stub). -> The SLF4J-shaped library is roughly feature-complete (47 unit tests passing). See -> `open-questions.md` for the explicit tracking list of items still missing on each -> side; we do not ask reviewers to compare designs until both libraries reach the same -> waterline. +For direct links from each Ecstasy type to its official SLF4J or Go `log/slog` +counterpart, see [`api-cross-reference.md`](api-cross-reference.md). + +> Status note: both libraries are working comparison POCs. `lib_logging` has the +> fuller SLF4J surface (54 unit tests plus the injected manual demo); `lib_slogging` +> has the smaller slog surface (34 unit tests plus injected/manual coverage), with +> runtime injection, `lib_json` JSON rendering, source metadata, context binding, and +> handler derivation semantics implemented. --- @@ -109,12 +112,12 @@ Both produce equivalent structured output. The shapes diverge in how | Axis | `lib_logging` (SLF4J 2.x) | `lib_slogging` (Go slog) | |---|---|---| | **Levels** | Five named: `Trace`, `Debug`, `Info`, `Warn`, `Error` (+ `Off`). Closed enum. | Integer `Level(severity)` with named constants `Debug=-4`, `Info=0`, `Warn=4`, `Error=8`. Open: callers can define `Level(2, "NOTICE")`. | -| **Context propagation** | Per-fiber `MDC` (`SharedContext`-backed map). Implicit — every emission snapshots it. | Explicit derived loggers via `Logger.with(attrs)`; no thread-local. Optionally a fiber-scoped logger via `SharedContext`. | +| **Context propagation** | Per-fiber `MDC` (`SharedContext`-backed map). Implicit — every emission snapshots it. | Explicit derived loggers via `Logger.with(attrs)` by default. Optional `LoggerContext` uses `SharedContext` when framework/request code wants implicit propagation. | | **Structured data** | `Marker` for category, `addKeyValue("k", v)` for KV pairs, `arguments` for `{}` substitution slots. Three concepts. | One concept: `Attr(key, value)`. Attributes are the first-class carrier. | | **Message** | Templated: `info("user {} did {}", [name, action])`. SLF4J-style `{}` placeholders. | Free-form string + attrs separately. No interpolation. | | **Sinks / handlers** | `LogSink.isEnabled(name, level, marker)`, `LogSink.log(event)`. Two methods. | `Handler.enabled(level)`, `Handler.handle(record)`, `Handler.withAttrs(attrs)`, `Handler.withGroup(name)`. Four methods — handler can pre-resolve attrs at derivation time. | -| **Logger naming** | Hierarchical names: `logger.named("payments")`, `payments.named("stripe")` → `"payments.stripe"`. SLF4J idiom. | Loggers are uniform; categorisation lives in attrs (`"component" -> "payments.stripe"`). | -| **Source location** | Not captured by default. | `Record.pc`/`Record.source` capture is opt-in but a first-class concern. | +| **Logger naming** | Hierarchical names: `logger.named("payments.stripe")`. The caller supplies the full category name, like `LoggerFactory.getLogger("...")`. | Loggers are uniform; categorisation lives in attrs (`"component" -> "payments.stripe"`). | +| **Source location** | Not captured by default. | `Logger.logAt(...)` explicitly populates `Record.sourceFile` / `sourceLine`; automatic call-site capture is future runtime/compiler work. | | **Async / batching** | Wrapper sink (`AsyncLogSink`, future) drains a bounded queue. | Wrapper handler — same shape. | | **Familiarity bench** | Java/Kotlin/Scala. Anyone who has done structured logging on the JVM. | Go (1.21+). Increasingly the modern go-to in cloud-native code. | @@ -155,22 +158,20 @@ In `lib_logging` the implementation is `SharedContext`-backed for per-fiber scop unlike Java MDC it doesn't leak into sibling fibers. That removes the worst class of real-world MDC bugs. But the *implicitness* remains. -**`With(attrs)`.** Explicit. Code reads "this logger carries these attributes." When a -function wants to log with a request id, it must accept the request-aware logger — -either as a parameter or as a field set up at construction. There is no global state. -The cost is verbosity: every layer that wants the request id either accepts a -`Logger` parameter or accepts a context object that can produce one. +**`With(attrs)`.** Explicit. Code reads "derive a handler that always includes these +attributes." When a function wants to log with a request id, it normally accepts the +request-aware logger — either as a parameter or as a field set up at construction. +`LoggerContext` exists for framework/request code that needs implicit propagation. In Go, slog reaches for `context.Context` to pass loggers through the call graph -without adding parameters everywhere. The Ecstasy equivalent is `SharedContext` -or service-local `Logger` fields — feasible but not idiomatic yet. +without adding parameters everywhere. The Ecstasy equivalent in this POC is +`LoggerContext`, a small `SharedContext` wrapper. **Ecstasy fit.** The MDC story is *already implemented and works*: `const MDC` over an -immutable-map `SharedContext` is small (~50 lines) and per-fiber. The slog story is -*mechanically simple* (just derive a new const Logger with extra attrs) but loses the -"library code that doesn't know about the request id still gets the request id" trick -unless the Logger parameter threads everywhere. We want reviewer thoughts on which -side of this tradeoff Ecstasy programs should be on. +immutable-map `SharedContext` is small and per-fiber. The slog story is similarly +small: explicit `Logger.with(...)` for normal code, optional `LoggerContext` for +framework code that wants the "library code still sees the request logger" trick. We +want reviewer thoughts on which side of this tradeoff Ecstasy programs should be on. ### 3.3 Structured data — three concepts vs one @@ -508,6 +509,12 @@ name=alice action=login` for the text handler. output; slog's structure-first is easier for machine consumption. The Ecstasy default arguments and varargs make either ergonomic. +**Formatter boundary.** `lib_logging.MessageFormatter` belongs only to the SLF4J-shaped +side. Go `log/slog` has no equivalent formatter: callers pass a finished message and +separate attrs. Reusing the SLF4J formatter in `lib_slogging` would erase the cleanest +part of slog's design by adding positional arguments next to attrs. If we want a +migration helper later, it should be an adapter outside the core slog API. + ### 3.5 Sink/handler shape — minimal vs richer `LogSink` has two methods. Everything (level filter, attr resolution, MDC capture, @@ -522,8 +529,10 @@ high-volume structured logging. **Ecstasy fit.** The slog handler shape is more work to implement but removes allocation on the hot path for derived loggers. Whether that matters in Ecstasy -depends on workload. The lib_slogging skeleton currently exposes both methods so -the cost is a couple more lines per handler. +depends on workload. `lib_slogging` now ships +[`BoundHandler`](../../lib_slogging/src/main/x/slogging/BoundHandler.x) as the default +derivation wrapper; a production backend can still override those hooks to cache a +serialized prefix or backend-native context object. ### 3.6 Logger naming — hierarchical vs attribute-based @@ -576,17 +585,17 @@ In `lib_logging`: In `lib_slogging`: -- `Logger` is a `const` carrying `Handler handler` and `Attr[] attrs`. Derivation - via `with(...)` returns a new `const Logger` — naturally immutable. -- There is no MDC, so no `SharedContext`-flavoured cross-cutting state. +- `Logger` is a `const` carrying `Handler handler`. Derivation via `with(...)` + returns a new `const Logger` with a derived handler — naturally immutable. +- `LoggerContext` is the optional `SharedContext` helper for framework/request + propagation; it is not required for normal logging calls. - Handlers split the same way: stateless adapters (`TextHandler`, `JSONHandler`, `NopHandler`) are `const`; stateful collectors (`MemoryHandler`) are `service`. -The slog model is *substantially* simpler on the const side because the absence of -MDC removes one of the two interactions with `SharedContext`. The other interaction -— "I want a logger that propagates through fibers without threading a parameter" — -is achievable with `SharedContext` if/when needed, but isn't required for the -core API. +The slog model is *substantially* simpler on the const side because the core API does +not snapshot an MDC map on every event. The optional interaction — "I want a logger +that propagates through fibers without threading a parameter" — lives in +`LoggerContext`, not in the hot path. ### 4.2 Fluent builder vs varargs @@ -694,7 +703,7 @@ lands. We lean — softly, fully expecting reviewer pushback — toward **`lib_slogging` as the canonical Ecstasy logging library, with one borrowed feature from `lib_logging`**: -the per-fiber `SharedContext`-backed MDC, recast as `slogging.Context` and *also* +the per-fiber `SharedContext` idea, recast as `LoggerContext` and *also* optional / never the only path. The reasoning, ranked: 1. **Conceptual economy.** One concept (`Attr`) covering markers, structured KVs, @@ -706,8 +715,8 @@ optional / never the only path. The reasoning, ranked: 3. **Less compiler ask.** No hierarchical naming, no fluent builder, no marker DAG. Smaller surface to support. 4. **MDC is genuinely useful.** Threading a request-aware logger through a - call graph is real work that real codebases get wrong. A `slogging.Context` with - `withAttrs(...)` semantics, *opt in*, gives you the SLF4J win without the + call graph is real work that real codebases get wrong. `LoggerContext` with + `Logger.with(...)` semantics, *opt in*, gives you the SLF4J win without the "everything is implicit" cost. A third option — **hybrid SLF4J-facade with slog-shaped event model** — is @@ -744,12 +753,16 @@ Q-D6 in `open-questions.md`. This branch (`lagergren/logging`) contains: -- `lib_logging/` — the SLF4J-shaped library, near-feature-complete (47 unit tests - passing). Tracking list of remaining items: `open-questions.md` § "SLF4J-style - library work not yet implemented". -- `lib_slogging/` — the slog-shaped library, currently a **skeleton** (interfaces + - one stub `TextHandler`). Full implementation gated on reviewer feedback on this - document. +- `lib_logging/` — the SLF4J-shaped library, near-feature-complete for the base API + (54 focused XTC test methods). Tracking list of remaining + items: `open-questions.md` § "SLF4J-style library work not yet implemented". +- `lib_slogging/` — the slog-shaped library, implemented to the same comparison + waterline with 34 focused XTC test methods. Runtime injection, `lib_json` JSON + rendering, explicit source + metadata, `LoggerContext`, handler derivation semantics, and a small handler + contract test kit are in scope for this branch. +- `api-cross-reference.md` — official SLF4J / Go slog API links mapped to local + Ecstasy source files and difference notes. - This document — the design comparison and the explicit list of reviewer questions. - `open-questions.md` — the unified question list (all "Q-D*" items added in this PR call out language-design questions specifically). diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index b5232e7966..aefb1c750e 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -220,7 +220,7 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | # | Item | Notes | |---|---|---| -| W-1 | Multiple markers per event | `LogEvent.marker: Marker?` → `LogEvent.markers: Marker[]`; accumulate in `BasicEventBuilder`; render `[markers=A,B]` in `ConsoleLogSink`. Tracked as task #2; see open question 4 above. | +| W-1 | Multiple markers per event | **Done.** `LogEvent.markers: Marker[]`, `marker` convenience accessor, `BasicEventBuilder.addMarker(...)` accumulation, and `ConsoleLogSink` multi-marker rendering are implemented and tested. | | W-2 | Logger interning in `Logger.named` | **Done.** Implemented `service LoggerRegistry(LogSink sink)` keyed by logger name; `BasicLogger.registry: LoggerRegistry?` (optional, defaulted `Null` so unattached loggers stay allocation-light); `LoggerFactory` lazily constructs a registry over its `defaultSink` and routes both `getLogger(...)` and `named(...)` through it. Tests: `NamedLoggerTest.shouldInternChildrenWhenRegistryAttached`, `NamedLoggerTest.shouldNotInternWhenRegistryAbsent`. | | W-3 | Real `MessageFormatter` | **Done.** Full SLF4J ParameterFormatter parity: `{}` substitution, `\{}` literal escape, `\\{}` double-escape, trailing-throwable promotion (with explicit-cause precedence enforced in `BasicLogger.emit`), defensive `safeToString`. 12 tests in `MessageFormatterTest`. The "stub" claim in earlier docs was stale. | | W-4 | Compiler-side default name from module | `@Inject Logger logger;` (no resourceName) should fall back to the enclosing module's qualified name; see plan Stage 4. | @@ -230,11 +230,11 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | W-8 | Per-container override convenience | Helper for child containers wanting a different sink. Open question 8. | | W-9 | Defensive copy of caller-supplied `Object[] arguments` | Open question 11. Decision: B (document, no copy) for v0. Listed here so it's not forgotten if v0 changes. | | W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | -| W-11 | Doc cleanup | `design/design.md` "What isn't here yet" still lists items that have since landed (RTLogger removed, MessageFormatter partial, tests now exist); `future/runtime-implementation-plan.md` and `usage/injected-logger-example.md` may reference the now-removed `xRTLogger`/`RTLogger.x`. | +| W-11 | Doc cleanup | **Done for active docs.** Start-here docs, parity docs, examples, runtime-injection notes, and source comments now describe the current `BasicLogger`/`MDC`/multi-marker/formatter shape. Historical future-plan docs remain as historical context. | -The same tracking shape will live for `lib_slogging` in -`lib-logging-vs-lib-slogging.md` so reviewers can see the two libraries reach feature -parity at the same waterline before we ask "which API do you prefer?" +The same tracking shape is mirrored for `lib_slogging` below so reviewers can see the +two libraries reach feature parity at the same waterline before we ask "which API do +you prefer?" ### Implementation tiers @@ -263,6 +263,21 @@ The `lib_slogging` parity translation: | W-11 doc cleanup | mirrored: keep `lib_slogging` doc references in sync | | (test suite parity) | mirror `LoggingTest` → `SLoggingTest`: per-level routing, fluent-equivalent (`logger.with`), level thresholds, exception carriage, structured attrs, group nesting | +## slog-style library (`lib_slogging`) — parity work addressed + +`lib_slogging` is now a working comparison POC at the same proof level as the +SLF4J-shaped library for the base API. The concrete parity gaps called out during +review have been closed as follows: + +| # | Item | Notes | +|---|---|---| +| S-1 | Runtime injection for `@Inject slogging.Logger logger;` | **Done.** `NativeContainer` now resolves resources by `(name, requested type)`, so `logging.Logger logger` and `slogging.Logger logger` can both use the default name. `manualTests/TestLogger.x` exercises both. | +| S-2 | Production JSON handler | **Done for the base POC.** `JSONHandler` renders through `lib_json`, escapes strings through `Printer`, preserves nested groups, emits source metadata, and renders exceptions structurally. Handler output destinations/options remain future backend work. | +| S-3 | Source-location capture | **Done via explicit API.** `Logger.logAt(level, message, sourceFile, sourceLine, attrs, cause)` populates `Record.sourceFile` / `sourceLine`. Automatic compiler/runtime call-site capture remains a future enhancement. | +| S-4 | Context story | **Done.** `LoggerContext` wraps `SharedContext` for framework/request propagation. Explicit logger passing remains the recommended default. | +| S-5 | Handler pre-resolution example | **Done.** `BoundHandler` implements the default `withAttrs` / `withGroup` semantics and shipped handlers use it; production backends can replace it with cached prefix implementations. | +| S-6 | Handler test kit | **Done.** `SLoggingTest.HandlerContract` provides a small `testing/slogtest`-style helper for third-party handler conformance. | + ## Decisions required to land in this order 1. **MDC scope (#3)** — must be made before Stage 1.3 of the plan. Without an answer @@ -270,8 +285,8 @@ The `lib_slogging` parity translation: 2. **Multiple markers per event (#4)** — should be made *before or with* Stage 1, so `LogEvent` and `BasicEventBuilder` shapes are stable when the runtime starts resolving them. -3. **Defensive copy (#11)** — make alongside Stage 1.1; the `RTLogger` Java side - needs to know whether to copy or not. +3. **Defensive copy (#11)** — decision is currently "document, no copy" for v0; revisit + before any async/default sink path captures caller-supplied mutable argument arrays. The remaining still-open items (#6, #7, #8, #12) can wait until there are real users. diff --git a/doc/logging/usage/custom-handlers.md b/doc/logging/usage/custom-handlers.md new file mode 100644 index 0000000000..46cfe078b7 --- /dev/null +++ b/doc/logging/usage/custom-handlers.md @@ -0,0 +1,176 @@ +# Writing a custom `Handler` + +`Handler` is the backend extension point for `lib_slogging`, the same way `LogSink` +is the backend extension point for `lib_logging`. + +Official Go reference: [`slog.Handler`](https://pkg.go.dev/log/slog#Handler). + +## The contract + +```ecstasy +interface Handler { + Boolean enabled(Level level); + void handle(Record record); + Handler withAttrs(Attr[] attrs); + Handler withGroup(String name); +} +``` + +The first two methods are the same basic shape as `LogSink`: cheap enabled check, +then emit the record. The second two methods are the slog-specific part. They are +called when user code derives a logger: + +```ecstasy +Logger requestLog = logger.with([Attr.of("requestId", req.id)]); +Logger grouped = requestLog.withGroup("payments"); +``` + +A handler that only counts/drops records may return `this` from both derivation +methods because attrs do not affect its behavior. A normal emitting handler should +either return `new BoundHandler(this, attrs/name)` for correct default semantics or +pre-render attrs/group prefixes into a handler-specific derived instance so each later +`handle(record)` does less work. + +## `const` or `service`? + +Same rule as `LogSink`: + +- **`const`** for stateless forwarders and fixed-configuration renderers: + `TextHandler`, `JSONHandler`, `NopHandler`. +- **`service`** for handlers with shared mutable state or resources: + `MemoryHandler`, a file writer, a network writer, an async queue. + +## Counting records by level + +```ecstasy +service CountingHandler + implements Handler { + + public/private Map counts = new HashMap(); + + @Override + Boolean enabled(Level level) { + return True; + } + + @Override + void handle(Record record) { + counts.process(record.level, e -> { + e.value = e.exists ? e.value + 1 : 1; + return Null; + }); + } + + @Override + Handler withAttrs(Attr[] attrs) = this; + + @Override + Handler withGroup(String name) = this; +} +``` + +Used like: + +```ecstasy +CountingHandler handler = new CountingHandler(); +Logger logger = new Logger(handler); + +logger.info("a"); +logger.warn("b"); + +assert handler.counts[Level.Info] == 1; +assert handler.counts[Level.Warn] == 1; +``` + +The same code exists as a unit-test helper in +[`lib_slogging/src/test/x/SLoggingTest/CountingHandler.x`](../../../lib_slogging/src/test/x/SLoggingTest/CountingHandler.x). + +## Reusing the handler contract checks + +The test suite includes a small `HandlerContract`, modelled on Go's +[`testing/slogtest`](https://pkg.go.dev/testing/slogtest), that third-party handlers +can reuse: + +```ecstasy +MemoryHandler handler = new MemoryHandler(); + +HandlerContract.assertWithAttrsPrepend(handler, () -> handler.records); +handler.reset(); +HandlerContract.assertWithGroupNests(handler, () -> handler.records); +``` + +Your own handler test can pass the handler instance plus a snapshot function that +returns the records it emitted. The contract checks that derived attrs are prepended +and groups are nested before the final handler observes the record. + +## Filtering audit events + +In the slog model, categories are attrs, not markers: + +```ecstasy +service AuditFilteringHandler(Handler delegate) + implements Handler { + + @Override + Boolean enabled(Level level) { + return delegate.enabled(level); + } + + @Override + void handle(Record record) { + for (Attr attr : record.attrs) { + if (attr.key == "audit" && attr.value == True) { + delegate.handle(record); + return; + } + } + } + + @Override + Handler withAttrs(Attr[] attrs) { + return new AuditFilteringHandler(delegate.withAttrs(attrs)); + } + + @Override + Handler withGroup(String name) { + return new AuditFilteringHandler(delegate.withGroup(name)); + } +} +``` + +Call site: + +```ecstasy +logger.info("user signed in", [ + Attr.of("audit", True), + Attr.of("user", userId), +]); +``` + +The SLF4J-shaped equivalent would use a marker. The slog-shaped equivalent keeps the +category in the same `Attr` channel as every other structured field. + +## Things handlers should not do + +- **Do not throw under normal failure modes.** If a remote endpoint is down, drop, + buffer, or fall back to console. +- **Do not do slow work in `enabled`.** It runs on every call before a record is built. +- **Do not parse the message to recover fields.** Fields belong in `Attr` values. +- **Do not add SLF4J-style placeholder formatting to the core handler path.** If a + migration adapter wants `"processed {}"` support, keep it outside `lib_slogging`. + +## What a production JSON handler still needs + +The shipped `JSONHandler` renders through `lib_json`, preserves nested groups, and +represents exceptions structurally. A production cloud sink built on the same handler +contract should still add: + +- output destination configuration (`Console`, file, stream, socket, HTTP exporter); +- support attr replacement/redaction rules; +- timestamp and level formatting options; +- expose an async wrapper for network or disk output. + + +--- + +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/usage/custom-sinks.md b/doc/logging/usage/custom-sinks.md index 02cca53845..6ede0fdc33 100644 --- a/doc/logging/usage/custom-sinks.md +++ b/doc/logging/usage/custom-sinks.md @@ -21,8 +21,7 @@ just decide: on the hot path. Must be cheap. 2. **`log`** — the event has been built; emit it. The `LogEvent.message` is already substituted; `mdcSnapshot` is captured. Sinks that want to render structured data - read `event.marker`, `event.exception`, `event.mdcSnapshot`, and (once added) - `event.keyValues`. + read `event.markers`, `event.exception`, `event.mdcSnapshot`, and `event.keyValues`. ## `const` or `service`? @@ -69,18 +68,21 @@ service CountingLogSink } ``` -Wiring it up — see `../open-questions.md` for the runtime side; for now, you can construct -a `BasicLogger` directly: +Wiring it up directly in tests or examples is just constructor injection: ```ecstasy -LogSink sink = new CountingLogSink(); -Logger logger = new BasicLogger("my.module", sink); +CountingLogSink sink = new CountingLogSink(); +Logger logger = new BasicLogger("my.module", sink); logger.info("hello"); logger.warn("uh oh"); assert sink.counts[Info] == 1; assert sink.counts[Warn] == 1; ``` +In a hosted application, the same sink becomes the active backend by registering it as +the `Logger` resource supplier for that container. The application code still receives +`@Inject Logger logger;`; only the host's resource map changes. + ## A more realistic example: writing to a file ```ecstasy @@ -227,8 +229,11 @@ service MarkerFilteringLogSink(LogSink delegate, Marker required) @Override void log(LogEvent event) { - if (Marker m ?= event.marker, m.contains(required)) { - delegate.log(event); + for (Marker m : event.markers) { + if (m.contains(required)) { + delegate.log(event); + return; + } } } } diff --git a/doc/logging/usage/ecstasy-vs-java-examples.md b/doc/logging/usage/ecstasy-vs-java-examples.md index 382a9ad9f6..cb34e74cfc 100644 --- a/doc/logging/usage/ecstasy-vs-java-examples.md +++ b/doc/logging/usage/ecstasy-vs-java-examples.md @@ -34,11 +34,12 @@ module Demo { } ``` -The SLF4J static-factory pattern still works in Ecstasy: +The equivalent of the SLF4J static-factory line is to inject once and derive the full +logger name you want: ```ecstasy -@Inject log.LoggerFactory factory; -log.Logger logger = factory.getLogger(Demo); +@Inject log.Logger root; +log.Logger logger = root.named("Demo"); ``` ## 2. Level checks (avoid expensive arg construction) @@ -173,14 +174,16 @@ Logger byClass = LoggerFactory.getLogger(MyClass.class); **Ecstasy:** ```ecstasy -@Inject log.LoggerFactory factory; -log.Logger byName = factory.getLogger("com.example.foo"); -log.Logger byClass = factory.getLogger(MyClass); - -// Or, by injection: -@Inject("com.example.foo") log.Logger logger; +@Inject log.Logger root; +log.Logger byName = root.named("com.example.foo"); +log.Logger byClass = root.named(MyClass.path); ``` +`@Inject("com.example.foo") log.Logger logger;` was intentionally rejected. The runtime +registers one root logger under the normal `@Inject Logger logger;` key; per-name +loggers are derived by API, which keeps the injector simple and mirrors SLF4J's +`LoggerFactory.getLogger(...)` idiom. + ## 8. Configuration: changing the root level **Java (`logback.xml`):** @@ -203,11 +206,11 @@ import org.slf4j.LoggerFactory; ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(Level.DEBUG); ``` -**Ecstasy (default `ConsoleLogSink`):** +**Ecstasy (explicit construction with the default `ConsoleLogSink`):** ```ecstasy -@Inject log.ConsoleLogSink sink; -sink.setRootLevel(log.Debug); +log.LogSink sink = new log.ConsoleLogSink(log.Level.Debug); +log.Logger logger = new log.BasicLogger("com.example", sink); ``` For a richer per-logger configuration tree see `../future/logback-integration.md` — the future diff --git a/doc/logging/usage/platform-and-examples-adaptation.md b/doc/logging/usage/platform-and-examples-adaptation.md index 7fdb46e8a0..defbe1b602 100644 --- a/doc/logging/usage/platform-and-examples-adaptation.md +++ b/doc/logging/usage/platform-and-examples-adaptation.md @@ -102,7 +102,8 @@ void start() { **After**: ```ecstasy -@Inject("kernel") log.Logger logger; +@Inject log.Logger rootLogger; +log.Logger logger = rootLogger.named("platform.kernel"); void start() { logger.info("Starting the AccountManager..."); @@ -175,7 +176,8 @@ This is the worst-offending file in the platform repo by call-site count. Twelve ```ecstasy service OAuthProvider { - @Inject("auth.oauth") log.Logger logger; + @Inject log.Logger rootLogger; + log.Logger logger = rootLogger.named("platform.auth.oauth"); void timeout(String provider) { logger.error("Authentication request to {} has timed out", [provider]); @@ -210,11 +212,13 @@ package gains a small canonical `Logger` per subsystem: package common { package log import logging.xtclang.org; - @Inject("platform.kernel") log.Logger kernelLogger; - @Inject("platform.auth") log.Logger authLogger; - @Inject("platform.host") log.Logger hostLogger; - @Inject("platform.proxy") log.Logger proxyLogger; - @Inject("platform.cli") log.Logger cliLogger; + @Inject log.Logger rootLogger; + + log.Logger kernelLogger = rootLogger.named("platform.kernel"); + log.Logger authLogger = rootLogger.named("platform.auth"); + log.Logger hostLogger = rootLogger.named("platform.host"); + log.Logger proxyLogger = rootLogger.named("platform.proxy"); + log.Logger cliLogger = rootLogger.named("platform.cli"); } ``` diff --git a/doc/logging/usage/slf4j-parity.md b/doc/logging/usage/slf4j-parity.md index fd1c0b08be..1299c88780 100644 --- a/doc/logging/usage/slf4j-parity.md +++ b/doc/logging/usage/slf4j-parity.md @@ -8,8 +8,8 @@ scan this once and know everything they need. | SLF4J 2.x (Java) | lib_logging (Ecstasy) | |---|---| -| `LoggerFactory.getLogger(MyClass.class)` | `@Inject Logger logger;` `static Logger LOG = logger.named(MyClass.qualifiedName);` | -| `LoggerFactory.getLogger("com.example.foo")` | `LoggerFactory.getLogger("com.example.foo")` *(also)* `logger.named("com.example.foo")` | +| `LoggerFactory.getLogger(MyClass.class)` | `@Inject Logger logger;` then `Logger LOG = logger.named(MyClass.path);` | +| `LoggerFactory.getLogger("com.example.foo")` | `logger.named("com.example.foo")` *(or `LoggerFactory.getLogger("com.example.foo")` when the non-injection factory is wired to a default sink)* | | (no equivalent) | `@Inject Logger logger;` *(injects a single root logger)* | `@Inject("com.example.foo") Logger logger;` was considered and rejected — see @@ -17,6 +17,11 @@ scan this once and know everything they need. parameterized injection annotation either; `LoggerFactory.getLogger(MyClass.class)` is its idiom and `Logger.named(String)` is the direct Ecstasy equivalent. +`Logger.named(String)` takes the full logger name. It does not append a suffix to the +current logger name; write `logger.named("billing.Charger")`, not +`logger.named("billing").named("Charger")`, unless the second call also supplies the +full name. + ## `Logger` core methods SLF4J's `Logger` defines five families (one per level) each with ~10 overloads. We collapse @@ -116,7 +121,7 @@ SLF4J's `{}` placeholder semantics are reproduced verbatim by `MessageFormatter. - Native injection (`@Inject Logger logger;`). SLF4J has nothing like this; it relies on the static factory. Injection makes per-container sink override trivial — see - `../open-questions.md` for the open piece around per-container overrides. + `../design/design.md` for the documented override pattern. - `Off` level, useful for sink configuration. SLF4J expresses this through level checks in `Logger.isEnabledFor(...)`. diff --git a/doc/logging/usage/slog-parity.md b/doc/logging/usage/slog-parity.md new file mode 100644 index 0000000000..b739be76ed --- /dev/null +++ b/doc/logging/usage/slog-parity.md @@ -0,0 +1,155 @@ +# Go `log/slog` → `lib_slogging` mapping + +This document is the slog-shaped companion to +[`slf4j-parity.md`](slf4j-parity.md). It maps the Go `log/slog` API to the Ecstasy +POC and calls out the differences that are intentional. + +Official reference: Go [`log/slog`](https://pkg.go.dev/log/slog). + +## Acquiring a logger + +| Go `log/slog` | `lib_slogging` | +|---|---| +| `slog.Default()` / top-level `slog.Info(...)` | Not modelled in v0. Ecstasy should prefer injected resources over process-global defaults. | +| `slog.New(handler)` | `new Logger(handler)` | +| `logger.With("requestId", id)` | `logger.with([Attr.of("requestId", id)])` | +| `logger.WithGroup("payments")` | `logger.withGroup("payments")` | +| `slog.NewTextHandler(w, opts)` | `new TextHandler(level)`; output currently goes through `@Inject Console`. | +| `slog.NewJSONHandler(w, opts)` | `new JSONHandler(level)`; renders through `lib_json` and writes through `@Inject Console`. | +| framework/request context | `LoggerContext.bind(logger)` / `LoggerContext.currentOr(root)` | + +Runtime `@Inject slogging.Logger logger;` is wired in this branch. The native injector +resolves resources by `(name, requested type)`, so both `logging.Logger logger` and +`slogging.Logger logger` can use the familiar default resource name `logger`. + +## `Logger` methods + +| Go `log/slog` | `lib_slogging` | +|---|---| +| `logger.Debug(msg, args...)` | `logger.debug(message, [Attr.of("k", v)])` | +| `logger.Info(msg, args...)` | `logger.info(message, attrs)` | +| `logger.Warn(msg, args...)` | `logger.warn(message, attrs)` | +| `logger.Error(msg, args...)` | `logger.error(message, attrs, cause=e)` | +| `logger.Log(ctx, level, msg, args...)` | `logger.log(level, message, attrs)` | +| `logger.LogAttrs(ctx, level, msg, attrs...)` | Same `logger.log(level, message, attrs)` path; attrs are already typed. | +| `logger.Enabled(ctx, level)` | `logger.enabled(level)` | +| `AddSource` output | `logger.logAt(level, message, sourceFile, sourceLine, attrs)` | + +Differences: + +- Go accepts alternating `"key", value` pairs and converts them into attrs. Ecstasy + uses explicit `Attr.of("key", value)` values, which avoids the bad-key case entirely. +- Go carries `context.Context`; the Ecstasy API keeps the common logging calls + context-free and offers `LoggerContext` as an Ecstasy `SharedContext` helper + for framework/request propagation. +- Go's `Error` convention is an attr such as `slog.Any("err", err)`. The Ecstasy POC + also has a dedicated `cause` parameter because `Exception` is a first-class type and + handlers/tests often want direct access. + +## `Attr` + +| Go `log/slog` | `lib_slogging` | +|---|---| +| `slog.String("user", user)` | `Attr.of("user", user)` | +| `slog.Int("count", count)` | `Attr.of("count", count)` | +| `slog.Bool("audit", true)` | `Attr.of("audit", True)` | +| `slog.Any("err", err)` | `Attr.of("err", e)` or `cause=e` | +| `slog.Group("user", ...)` | `Attr.group("user", [...])` | + +Go has many typed helper functions because it has no common `Object` parent. Ecstasy +does, so a single `Attr.of` is enough for the POC. A production handler can still +render strings, numbers, booleans, times, durations, exceptions, and groups differently. + +## `Handler` + +| Go `log/slog.Handler` | `lib_slogging.Handler` | +|---|---| +| `Enabled(context.Context, Level) bool` | `enabled(Level)` | +| `Handle(context.Context, Record) error` | `handle(Record)` | +| `WithAttrs([]Attr) Handler` | `withAttrs(Attr[])` | +| `WithGroup(string) Handler` | `withGroup(String)` | + +The extra derivation methods are the key architectural difference from +`lib_logging.LogSink`. They let a handler pre-bind attrs or a group once when a +logger is derived, instead of doing that work on every record. The shipped handlers use +`BoundHandler` for the default semantics, and tests include a small +`HandlerContract` helper modelled on Go's `testing/slogtest`. + +## Context and source metadata + +The default Ecstasy style is explicit: + +```ecstasy +Logger reqLog = logger.with([Attr.of("requestId", req.id)]); +reqLog.info("processing", [Attr.of("path", req.path)]); +``` + +When framework code needs a request logger to flow through code that does not accept a +logger parameter, bind it with `LoggerContext`: + +```ecstasy +LoggerContext context = new LoggerContext(); + +using (context.bind(reqLog)) { + worker.process^(); +} + +Logger log = context.currentOr(logger); +log.info("inside worker"); +``` + +Source metadata is explicit for now: + +```ecstasy +logger.logAt(Level.Info, "processing", "PaymentService.x", 42, + [Attr.of("requestId", req.id)]); +``` + +That fills `Record.sourceFile` and `Record.sourceLine`, and `JSONHandler` renders it +under the `"source"` object. Automatic call-site capture can target this method later. + +## Levels + +| Go `log/slog` | `lib_slogging` | +|---|---| +| `slog.LevelDebug` (`-4`) | `Level.Debug` | +| `slog.LevelInfo` (`0`) | `Level.Info` | +| `slog.LevelWarn` (`4`) | `Level.Warn` | +| `slog.LevelError` (`8`) | `Level.Error` | +| custom `slog.Level(2)` | `new Level(2, "NOTICE")` | + +This is one place where the slog model is materially different from SLF4J: +levels are open. That is more extensible, but less canonical. + +## Message formatting + +There is no slog equivalent to SLF4J's `MessageFormatter`. This is intentional: + +```ecstasy +// SLF4J-shaped +logger.info("processed {} records", [count]); + +// slog-shaped +logger.info("processed records", [Attr.of("count", count)]); +``` + +The slog design keeps the human message stable and puts variable data in attrs. Reusing +`lib_logging.MessageFormatter` in `lib_slogging` would add positional arguments next +to attrs and remove the model's main simplification. If migration sugar is useful +later, it should live in an adapter module, not in the core slog API. + +## What remains intentionally smaller than Go `log/slog` + +- Top-level process-global functions such as `slog.Info(...)` are not modelled; + Ecstasy should prefer injected resources. +- Output destinations/options are not yet modelled as a `HandlerOptions` object; + `TextHandler` and `JSONHandler` write to injected `Console`. +- Automatic compiler/runtime source-location capture is not implemented; `logAt(...)` + is the explicit API that future sugar can target. +- `BoundHandler` provides correct derivation semantics. A high-performance production + backend can still override `withAttrs` / `withGroup` to cache serialized prefixes. + + +--- + +_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/usage/structured-logging.md b/doc/logging/usage/structured-logging.md index c099411fbc..b35624b4e6 100644 --- a/doc/logging/usage/structured-logging.md +++ b/doc/logging/usage/structured-logging.md @@ -97,34 +97,34 @@ break when richer sinks arrive. ### Layer 2 — event data model -The `LogEvent` const should grow a `keyValues` field analogous to SLF4J's -`getKeyValuePairs()`. The current stub does not have it; this is one of the v0.1 follow-ups -tracked in `../open-questions.md`. The intended shape: +The `LogEvent` const carries a `keyValues` field analogous to SLF4J's +`getKeyValuePairs()`: ```ecstasy const LogEvent( String loggerName, Level level, String message, - Marker? marker = Null, + Marker[] markers = [], Exception? exception = Null, Object[] arguments = [], - immutable Map keyValues = Map:[], // ← add - immutable Map mdcSnapshot = Map:[], + Map keyValues = [], + Map mdcSnapshot = [], String threadName = "", Time timestamp, ); ``` -The `BasicEventBuilder` accumulates KV pairs in a local map (currently a TODO) and passes -them through when `log()` materializes the event. +`BasicEventBuilder` accumulates KV pairs in a local `ListMap`, freezes a snapshot, and +passes them through when `log()` materializes the event. Duplicate keys currently use +"last value wins" semantics; the tests pin that behavior down. ### Layer 3 — sink `LogSink` already gets the whole `LogEvent`, so a sink that wants to render KV pairs -just reads `event.keyValues`. The default `ConsoleLogSink` is dumb on purpose: it would -append KV pairs as `key=value key=value` after the message, in the same shape Logback's -default `PatternLayout` uses for MDC. +just reads `event.keyValues`. The default `ConsoleLogSink` is intentionally simple: it +appends KV pairs as `{key=value, key=value}` after the message, in the same spirit as +Logback's simple text layouts. Structured sinks can render the same fields as real JSON. A KV-aware structured sink — say `JsonLineLogSink` — would render every event as a single JSON object per line: @@ -158,8 +158,8 @@ service JsonLineLogSink appendField(json, "level", event.level.name); appendField(json, "logger", event.loggerName); appendField(json, "message", event.message); - if (Marker m ?= event.marker) { - appendField(json, "marker", m.name); + if (!event.markers.empty) { + appendArray(json, "markers", event.markers.map(m -> m.name)); } for ((String k, Object v) : event.keyValues) { appendField(json, k, v); @@ -205,12 +205,13 @@ in the complex case). See `../future/logback-integration.md`. ## What to build first vs. later -For v0.1, the right order is: +For the next production-oriented cut, the right order is: -1. Add `keyValues: Map` to `LogEvent` and wire it through - `BasicEventBuilder.addKeyValue`. -2. Make `ConsoleLogSink` append KV pairs in `key=value` form after the message. -3. Add a `JsonLineLogSink` that renders one JSON object per event (uses `lib_json`). +1. Add a `JsonLineLogSink` that renders one JSON object per event using `lib_json`. +2. Decide whether duplicate structured keys should remain "last value wins" (`Map`) or + preserve duplicates (`KeyValuePair[]`, closer to SLF4J 2.x). +3. Add cloud-oriented layouts that map MDC, markers, and key/value pairs to the field + names expected by GCP / AWS / Azure shippers. Everything beyond that — typed value formatting, schema validation, OpenTelemetry integration, log-correlation IDs propagated across services — is sink-side. The API diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f88d701695..2ddc2636a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -138,6 +138,7 @@ xdk-ecstasy = { group = "org.xtclang", name = "lib-ecstasy" } xdk-json = { group = "org.xtclang", name = "lib-json" } xdk-jsondb = { group = "org.xtclang", name = "lib-jsondb" } xdk-logging = { group = "org.xtclang", name = "lib-logging" } +xdk-slogging = { group = "org.xtclang", name = "lib-slogging" } xdk-net = { group = "org.xtclang", name = "lib-net" } xdk-oodb = { group = "org.xtclang", name = "lib-oodb" } xdk-sec = { group = "org.xtclang", name = "lib-sec" } diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index ac04746c5e..c4dcd7fe20 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -111,14 +111,19 @@ private ConstantPool loadNativeTemplates() { ModuleStructure moduleRoot = f_repository.loadModule(ECSTASY_MODULE); ModuleStructure moduleTurtle = f_repository.loadModule(TURTLE_MODULE); ModuleStructure moduleNative = f_repository.loadModule(NATIVE_MODULE); + ModuleStructure moduleLogging = f_repository.loadModule("logging.xtclang.org"); + ModuleStructure moduleSLogging = f_repository.loadModule("slogging.xtclang.org"); - if (moduleRoot == null || moduleTurtle == null || moduleNative == null) { + if (moduleRoot == null || moduleTurtle == null || moduleNative == null + || moduleLogging == null || moduleSLogging == null) { throw new IllegalStateException("Native libraries are missing"); } // "root" is a merge of "native" module into the "system" FileStructure fileRoot = new FileStructure(moduleRoot, true); fileRoot.merge(moduleTurtle, true, false); + fileRoot.merge(moduleLogging, true, false); + fileRoot.merge(moduleSLogging, true, false); fileRoot.merge(moduleNative, true, false); fileRoot.linkModules(f_repository, true); @@ -374,14 +379,16 @@ private void initResources(ConstantPool pool) { TypeConstant typeHashCollector = templateHashCollector.getCanonicalType(); addResourceSupplier(new InjectionKey("hash", typeHashCollector), templateHashCollector::ensureCollector); - // +++ logging.Logger and logging.MDC - // Both back `@Inject Logger logger;` and `@Inject MDC mdc;` on the user side. They - // are both `const` types in `lib_logging`, deliberately NOT services: a service - // wrapper around `Logger` would create a new fiber per call, and the [MDC] tokens - // (registered via `SharedContext.withValue` on the caller's fiber) would not be - // visible to `BasicLogger.emit` running on the wrapper's fiber. Constructing the - // `const` directly hands the user a Passable handle whose methods execute on their - // own fiber, preserving fiber-local MDC propagation end-to-end. + // +++ logging.Logger, logging.MDC, and slogging.Logger + // These back `@Inject Logger logger;`, `@Inject MDC mdc;`, and + // `@Inject slogging.Logger logger;` on the user side. The two logger resources use + // the same resource name, so getInjectable() must resolve by both name and type. + // + // The logger implementations are `const` types, deliberately NOT services: a service + // wrapper around a logger would create a new fiber per call. That would break + // fiber-local context such as logging.MDC and slogging.LoggerContext, because the + // SharedContext tokens registered on the caller's fiber would not be visible from + // inside the wrapper service. // // The supplier shape is the same as for any other native-injected resource: one // `(typeName, resourceName)` registration, no wildcard branch in `getInjectable`. @@ -401,6 +408,19 @@ private void initResources(ConstantPool pool) { pool.ensureClassConstant(modLogging, "MDC")); addResourceSupplier(new InjectionKey("mdc", typeMDC), (frame, hOpts) -> ensureConst(frame, typeMDC)); + + ModuleConstant modSLogging = pool.ensureModuleConstant("slogging.xtclang.org"); + TypeConstant typeSLogger = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modSLogging, "Logger")); + addResourceSupplier(new InjectionKey("logger", typeSLogger), + (frame, hOpts) -> { + ConstantPool poolFrame = frame.poolContext(); + TypeConstant typeFrameSLogger = poolFrame.ensureTerminalTypeConstant( + poolFrame.ensureClassConstant( + poolFrame.ensureModuleConstant("slogging.xtclang.org"), + "Logger")); + return ensureConst(frame, typeFrameSLogger); + }); } /** @@ -468,7 +488,6 @@ private void addResourceSupplier(InjectionKey key, InjectionSupplier supplier) { assert !f_mapResources.containsKey(key); f_mapResources.put(key, supplier); - f_mapResourceNames.put(key.f_sName, key); } public ObjectHandle ensureOSStorage(Frame frame, ObjectHandle hOpts) { @@ -756,21 +775,27 @@ public ConstantPool getConstantPool() { @Override public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, ObjectHandle hOpts) { - InjectionKey key = f_mapResourceNames.get(sName); - if (key == null) { - // for "Nullable" types the NativeContainer can only supply a trivial result; - // anything better than that must be done naturally by a container that hosts the - // calling container - return type.isNullable() ? xNullable.NULL : null; + for (Map.Entry entry : f_mapResources.entrySet()) { + InjectionKey key = entry.getKey(); + if (!key.f_sName.equals(sName)) { + continue; + } + + // Check for equality first, but allow "congruency", "duck type" equality as well + // as sans-Nullable equivalency. This deliberately resolves by (name, type), not just + // name, so `logging.Logger logger` and `slogging.Logger logger` can both use the + // familiar resource name "logger". + TypeConstant typeResource = key.f_type; + if (typeResource.equals(type) || typeResource.isEquivalent(type) + || typeResource.isEquivalent(type.removeNullable())) { + return entry.getValue().supply(frame, hOpts); + } } - // check for equality first, but allow "congruency", "duck type" equality as well or - // sans-Nullable equivalency - TypeConstant typeResource = key.f_type; - return typeResource.equals(type) || typeResource.isEquivalent(type) - || typeResource.isEquivalent(type.removeNullable()) - ? f_mapResources.get(key).supply(frame, hOpts) - : null; + // for "Nullable" types the NativeContainer can only supply a trivial result; + // anything better than that must be done naturally by a container that hosts the + // calling container + return type.isNullable() ? xNullable.NULL : null; } @Override @@ -812,6 +837,7 @@ public FileStructure createFileStructure(ModuleStructure moduleApp) { fileApp.merge(m_moduleTurtle, false, false); fileApp.merge(f_repository.loadModule("crypto.xtclang.org"), true, false); fileApp.merge(f_repository.loadModule("logging.xtclang.org"), true, false); + fileApp.merge(f_repository.loadModule("slogging.xtclang.org"), true, false); fileApp.merge(f_repository.loadModule("net.xtclang.org"), true, false); fileApp.merge(f_repository.loadModule("web.xtclang.org"), true, false); fileApp.merge(m_moduleNative, false, false); @@ -963,10 +989,8 @@ public String toString() { /** * Map of resource names for a name based lookup. */ - private final Map f_mapResourceNames = new HashMap<>(); - /** * Map of resources that are injectable from this container, keyed by their InjectionKey. */ private final Map f_mapResources = new HashMap<>(); -} \ No newline at end of file +} diff --git a/lib_logging/README.md b/lib_logging/README.md index 42cfa8de27..34d6d46d62 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -2,9 +2,10 @@ Experimental SLF4J-shaped logging library for Ecstasy. -> **Status:** Stub / research branch. The API is intended to be the long-lived shape; -> implementations and the runtime injection plumbing are not wired up yet. See the design -> docs under [`doc/logging/`](../doc/logging) at the repo root. +> **Status:** Working POC. The core API, default sinks, MDC, markers, structured +> key/value events, logger interning, runtime `@Inject Logger logger;` wiring, and +> unit tests are in this branch. JIT-side injection, async sinks, and the +> Logback-style backend remain future work; see [`doc/logging/`](../doc/logging). ## What this is @@ -14,7 +15,7 @@ markers, MDC, the SLF4J 2.x fluent event builder. Acquired by injection: ```ecstasy @Inject Logger logger; -logger.info("processed {} records in {}ms", count, elapsed); +logger.info("processed {} records in {}ms", [count, elapsed]); ``` The default sink writes to the platform `Console`. The `LogSink` SPI is the API↔impl @@ -25,7 +26,11 @@ without touching caller code. All design docs live at the repo root under [`doc/logging/`](../doc/logging): -- [`PLAN.md`](../doc/logging/PLAN.md) — master plan, scope, ordering of work +- [`README.md`](../doc/logging/README.md) — start-here guide and reading paths +- [`api-cross-reference.md`](../doc/logging/api-cross-reference.md) — official SLF4J links mapped + to each Ecstasy type and the local differences +- [`lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) — + side-by-side comparison with the slog-shaped sibling library - [`design.md`](../doc/logging/design/design.md) — architecture, module layout, API↔impl boundary - [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) — every SLF4J 2.x type and method, mapped - [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) — Java SLF4J diff --git a/lib_logging/src/main/x/logging.x b/lib_logging/src/main/x/logging.x index b7429e3059..7830dd7c20 100644 --- a/lib_logging/src/main/x/logging.x +++ b/lib_logging/src/main/x/logging.x @@ -14,9 +14,9 @@ * * @Inject Logger logger; * - * or, naming the logger explicitly: + * Per-name loggers are derived from the injected logger: * - * @Inject("com.example.thing") Logger logger; + * Logger payments = logger.named("com.example.payments"); * * Acquiring a logger without injection is also supported via `LoggerFactory`: * @@ -40,7 +40,11 @@ * - [MemoryLogSink] — captures events in memory; useful in tests * * A future `lib_logging_logback` module is expected to ship a configuration-driven sink with - * appenders, layouts, filters, and per-logger thresholds — see `docs/logback-integration.md`. + * appenders, layouts, filters, and per-logger thresholds — see + * `doc/logging/future/logback-integration.md`. + * + * For official SLF4J API links mapped back to these Ecstasy types, see + * `doc/logging/api-cross-reference.md`. */ module logging.xtclang.org { } diff --git a/lib_logging/src/main/x/logging/BasicEventBuilder.x b/lib_logging/src/main/x/logging/BasicEventBuilder.x index ee78069279..d24fc655fa 100644 --- a/lib_logging/src/main/x/logging/BasicEventBuilder.x +++ b/lib_logging/src/main/x/logging/BasicEventBuilder.x @@ -15,36 +15,57 @@ class BasicEventBuilder(BasicLogger logger, Level level) private Exception? cause = Null; private Map keyValues = new ListMap(); + /** + * Replace the pending message pattern. Mirrors SLF4J + * `LoggingEventBuilder.setMessage`. + */ @Override LoggingEventBuilder setMessage(String message) { this.message = message; return this; } + /** + * Append one positional `{}` argument. + */ @Override LoggingEventBuilder addArgument(Object value) { args.add(value); return this; } + /** + * Attach one marker. Repeated calls produce a multi-marker event. + */ @Override LoggingEventBuilder addMarker(Marker marker) { markers.add(marker); return this; } + /** + * Attach an explicit cause. This wins over SLF4J throwable promotion from + * `arguments`. + */ @Override LoggingEventBuilder setCause(Exception cause) { this.cause = cause; return this; } + /** + * Attach one structured key/value pair for structured sinks. Duplicate keys use + * last-value-wins semantics in the current `Map` representation. + */ @Override LoggingEventBuilder addKeyValue(String key, Object value) { keyValues.put(key, value); return this; } + /** + * Emit using the message set by `setMessage`, if one was supplied. + */ @Override void log() { if (String m ?= message) { @@ -52,17 +73,26 @@ class BasicEventBuilder(BasicLogger logger, Level level) } } + /** + * Emit using `message` as the final message pattern. + */ @Override void log(String message) { logger.emitWith(level, message, frozen(args), cause, frozenMarkers(), frozenKVs()); } + /** + * Convenience: append one argument and emit. + */ @Override void log(String format, Object arg) { args.add(arg); logger.emitWith(level, format, frozen(args), cause, frozenMarkers(), frozenKVs()); } + /** + * Convenience: append two arguments and emit. + */ @Override void log(String format, Object arg1, Object arg2) { args.add(arg1); @@ -70,6 +100,9 @@ class BasicEventBuilder(BasicLogger logger, Level level) logger.emitWith(level, format, frozen(args), cause, frozenMarkers(), frozenKVs()); } + /** + * Convenience: append all supplied arguments and emit. + */ @Override void log(String format, Object[] args) { for (Object arg : args) { diff --git a/lib_logging/src/main/x/logging/BasicMarker.x b/lib_logging/src/main/x/logging/BasicMarker.x index dde3403ec3..dbe55e9031 100644 --- a/lib_logging/src/main/x/logging/BasicMarker.x +++ b/lib_logging/src/main/x/logging/BasicMarker.x @@ -11,6 +11,9 @@ class BasicMarker(String name) private Marker[] children = new Marker[]; + /** + * Add a direct child reference unless it is already present. + */ @Override void add(Marker reference) { if (!children.contains(reference)) { @@ -18,21 +21,33 @@ class BasicMarker(String name) } } + /** + * Remove a direct child reference by marker equality. + */ @Override Boolean remove(Marker reference) { return children.removeIfPresent(reference); } + /** + * True when this marker has direct children. + */ @Override @RO Boolean hasReferences.get() { return !children.empty; } + /** + * Iterator over direct child markers. + */ @Override @RO Iterator references.get() { return children.iterator(); } + /** + * Transitive marker containment check by marker. + */ @Override Boolean contains(Marker other) { if (this.name == other.name) { @@ -46,6 +61,9 @@ class BasicMarker(String name) return False; } + /** + * Transitive marker containment check by name. + */ @Override Boolean containsName(String name) { if (this.name == name) { diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x index e1e5431f56..9a6ce66408 100644 --- a/lib_logging/src/main/x/logging/ConsoleLogSink.x +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -47,11 +47,19 @@ const ConsoleLogSink(Level rootLevel) @Inject Console console; + /** + * Cheap root-threshold check. The default sink intentionally has no per-logger or + * marker-specific configuration; those belong in richer sinks. + */ @Override Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { return level.severity >= rootLevel.severity; } + /** + * Render a `LogEvent` as one human-readable line. This default output is stable + * enough for demos/tests, not a replacement for a Logback-style layout system. + */ @Override void log(LogEvent event) { StringBuffer buf = new StringBuffer(); diff --git a/lib_logging/src/main/x/logging/Logger.x b/lib_logging/src/main/x/logging/Logger.x index 1b40af71d2..0c6fdf1a72 100644 --- a/lib_logging/src/main/x/logging/Logger.x +++ b/lib_logging/src/main/x/logging/Logger.x @@ -11,8 +11,8 @@ * * Acquisition is by injection: * - * @Inject Logger logger; // anonymous logger - * @Inject("com.example.foo") Logger logger; // named logger + * @Inject Logger logger; // root logger + * Logger log = logger.named("com.example.foo"); // named logger * * or via the `LoggerFactory` static accessor. * @@ -34,15 +34,33 @@ interface Logger { * @Inject Logger logger; // root logger, fixed name * static Logger PaymentLogger = logger.named("payments"); // per-class derivative * - * Implementations are free to intern by name (and `BasicLogger` may grow that later); - * for v0 each call returns a fresh `Logger`. + * `name` is the full logger name, not a suffix appended to this logger's name. + * Implementations may intern by name; `BasicLogger` does so when it was created by a + * `LoggerRegistry` and otherwise returns a fresh logger. */ Logger named(String name); + /** + * Cheap `Trace` level check. + */ @RO Boolean traceEnabled; + /** + * Cheap `Debug` level check. Use this before constructing expensive debug + * arguments; `{}` formatting is lazy, but argument expressions are still evaluated + * by the caller before the method call. + */ @RO Boolean debugEnabled; + /** + * Cheap `Info` level check. + */ @RO Boolean infoEnabled; + /** + * Cheap `Warn` level check. + */ @RO Boolean warnEnabled; + /** + * Cheap `Error` level check. + */ @RO Boolean errorEnabled; /** @@ -53,26 +71,41 @@ interface Logger { // ---- Per-level emission ------------------------------------------------------------------- + /** + * Emit a `Trace` event using SLF4J `{}` placeholder formatting. + */ void trace(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null); + /** + * Emit a `Debug` event using SLF4J `{}` placeholder formatting. + */ void debug(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null); + /** + * Emit an `Info` event using SLF4J `{}` placeholder formatting. + */ void info (String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null); + /** + * Emit a `Warn` event using SLF4J `{}` placeholder formatting. + */ void warn (String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null); + /** + * Emit an `Error` event using SLF4J `{}` placeholder formatting. + */ void error(String message, Object[] arguments = [], Exception? cause = Null, @@ -99,9 +132,24 @@ interface Logger { * .log("processing failed for {}", input); */ LoggingEventBuilder atTrace(); + /** + * Begin a fluent log event at `Debug` level. + */ LoggingEventBuilder atDebug(); + /** + * Begin a fluent log event at `Info` level. + */ LoggingEventBuilder atInfo(); + /** + * Begin a fluent log event at `Warn` level. + */ LoggingEventBuilder atWarn(); + /** + * Begin a fluent log event at `Error` level. + */ LoggingEventBuilder atError(); + /** + * Begin a fluent log event at a runtime-selected level. + */ LoggingEventBuilder atLevel(Level level); } diff --git a/lib_logging/src/main/x/logging/LoggerFactory.x b/lib_logging/src/main/x/logging/LoggerFactory.x index 76c37b62a0..e3fe0c540c 100644 --- a/lib_logging/src/main/x/logging/LoggerFactory.x +++ b/lib_logging/src/main/x/logging/LoggerFactory.x @@ -19,8 +19,17 @@ */ service LoggerFactory { + /** + * The active default backend. This is intended to be supplied by the host + * container; if no richer sink is registered, the runtime can fall back to the + * console sink. + */ @Inject LogSink defaultSink; + /** + * Lazily-created name cache scoped to the injected sink. Mirrors SLF4J's + * `ILoggerFactory` identity-stability rule without creating JVM-global state. + */ private @Lazy LoggerRegistry registry.calc() { return new LoggerRegistry(defaultSink); } diff --git a/lib_logging/src/main/x/logging/LoggerRegistry.x b/lib_logging/src/main/x/logging/LoggerRegistry.x index ef12141fee..872cc2b0f3 100644 --- a/lib_logging/src/main/x/logging/LoggerRegistry.x +++ b/lib_logging/src/main/x/logging/LoggerRegistry.x @@ -1,12 +1,12 @@ /** * Corresponds to the role played by `org.slf4j.ILoggerFactory` impls (Logback's * `LoggerContext`, Log4j 2's `LoggerContext`): the central name-keyed cache that - * makes repeated `getLogger("a.b")` / `parent.named("b")` calls return the same + * makes repeated `getLogger("a.b")` / `logger.named("a.b")` calls return the same * `Logger` instance. * - * A name-keyed intern cache for `Logger` instances. `BasicLogger.named(child)` consults - * an attached registry — when present — and returns the cached logger for the resulting - * `"."` name; without a registry, each call allocates a fresh logger. + * A name-keyed intern cache for `Logger` instances. `BasicLogger.named(name)` consults + * an attached registry — when present — and returns the cached logger for that exact + * full name; without a registry, each call allocates a fresh logger. * * # Why interning belongs in a separate service * @@ -27,7 +27,7 @@ * LoggerRegistry registry = new LoggerRegistry(testSink); * Logger root = registry.ensure("root"); * assert &root == ®istry.ensure("root"); // same instance - * assert &root.named("a.b") == ®istry.ensure("root.a.b"); + * assert &root.named("a.b") == ®istry.ensure("a.b"); */ service LoggerRegistry(LogSink sink) { diff --git a/lib_logging/src/main/x/logging/LoggingEventBuilder.x b/lib_logging/src/main/x/logging/LoggingEventBuilder.x index 69d9aa2752..666af3d4dc 100644 --- a/lib_logging/src/main/x/logging/LoggingEventBuilder.x +++ b/lib_logging/src/main/x/logging/LoggingEventBuilder.x @@ -4,10 +4,15 @@ * * Fluent log-event builder, mirroring `org.slf4j.spi.LoggingEventBuilder` from SLF4J 2.x. * - * The builder accumulates state then materializes a single `LogEvent` on `log(...)`. When the - * underlying logger is _not_ enabled at the chosen level, the builder is a no-op and all - * accumulated state is discarded — this is the whole point of the design: callers don't pay - * for arguments and cause-chains that are never going to be emitted. + * The builder accumulates state then materializes a single `LogEvent` on `log(...)`. The + * level check happens at `log(...)`, after markers have been attached, so marker-aware + * sinks still get a chance to enable or suppress the event. If the sink rejects the event, + * no formatting, MDC snapshot, or `LogEvent` allocation happens. + * + * This is deliberately a v0 trade-off: the builder stores values passed to + * `addArgument` / `addKeyValue`, so caller expressions have already been evaluated by + * the time those methods run. Future lazy overloads can add supplier-valued arguments; + * see `doc/logging/future/lazy-logging.md`. * * Example: * diff --git a/lib_logging/src/main/x/logging/MarkerFactory.x b/lib_logging/src/main/x/logging/MarkerFactory.x index fa2665b3f5..432c76cb5d 100644 --- a/lib_logging/src/main/x/logging/MarkerFactory.x +++ b/lib_logging/src/main/x/logging/MarkerFactory.x @@ -11,13 +11,14 @@ */ service MarkerFactory { + private Map markers = new HashMap(); + /** * Get (creating if necessary) the canonical marker with the supplied name. Subsequent calls * with the same `name` return the same marker. */ Marker getMarker(String name) { - // TODO(impl): thread-safe interning map. - return new BasicMarker(name); + return markers.computeIfAbsent(name, () -> new BasicMarker(name)); } /** @@ -31,7 +32,6 @@ service MarkerFactory { * True iff a marker with the supplied name has previously been interned. */ Boolean exists(String name) { - // TODO(impl): consult the interning map. - return False; + return markers.contains(name); } } diff --git a/lib_logging/src/test/x/LoggingTest.x b/lib_logging/src/test/x/LoggingTest.x index e5ebfc54a9..858a241de7 100644 --- a/lib_logging/src/test/x/LoggingTest.x +++ b/lib_logging/src/test/x/LoggingTest.x @@ -6,7 +6,7 @@ * - the level check fast-path elides emission of disabled events; * - the per-level methods (`trace`, `debug`, `info`, `warn`, `error`) route correctly; * - markers and exceptions arrive at the sink intact; - * - the fluent event builder accumulates state and short-circuits on disabled levels; + * - the fluent event builder accumulates state and drops disabled events at emission; * - `MessageFormatter` substitutes `{}` placeholders. * * Each test class targets one cohesive area; submodules are picked up automatically by diff --git a/lib_logging/src/test/x/LoggingTest/MarkerFactoryTest.x b/lib_logging/src/test/x/LoggingTest/MarkerFactoryTest.x new file mode 100644 index 0000000000..c8614c71fe --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/MarkerFactoryTest.x @@ -0,0 +1,43 @@ +import logging.Marker; +import logging.MarkerFactory; + +/** + * Tests for `MarkerFactory` interning semantics. SLF4J users expect + * `MarkerFactory.getMarker("AUDIT")` to return a canonical marker for that name, while + * `getDetachedMarker(...)` returns an unregistered, one-off marker. + */ +class MarkerFactoryTest { + + @Test + void shouldInternMarkersByName() { + MarkerFactory factory = new MarkerFactory(); + + Marker first = factory.getMarker("AUDIT"); + Marker second = factory.getMarker("AUDIT"); + + assert &first == &second; + assert first.name == "AUDIT"; + } + + @Test + void shouldTrackInternedMarkerExistence() { + MarkerFactory factory = new MarkerFactory(); + + assert !factory.exists("SECURITY"); + + factory.getMarker("SECURITY"); + + assert factory.exists("SECURITY"); + } + + @Test + void shouldCreateDetachedMarkersOutsideTheInternCache() { + MarkerFactory factory = new MarkerFactory(); + + Marker detached = factory.getDetachedMarker("AUDIT"); + + assert !factory.exists("AUDIT"); + Marker interned = factory.getMarker("AUDIT"); + assert &detached != &interned; + } +} diff --git a/lib_slogging/README.md b/lib_slogging/README.md new file mode 100644 index 0000000000..8522049fc8 --- /dev/null +++ b/lib_slogging/README.md @@ -0,0 +1,51 @@ +# lib_slogging + +Experimental Go `log/slog`-shaped structured logging library for Ecstasy. + +> **Status:** Working comparison POC. The core `Logger` / `Handler` / `Record` / +> `Attr` / `Level` shape, text/JSON/no-op/memory handlers, derived loggers, groups, +> custom levels, source metadata, `LoggerContext`, runtime injection, and unit tests +> are in this branch. `JSONHandler` renders through `lib_json`. + +## What this is + +`lib_slogging` is the attribute-first alternative to the SLF4J-shaped +[`lib_logging`](../lib_logging). It is familiar to Go engineers: + +```ecstasy +Logger logger = new Logger(new TextHandler()); + +Logger requestLog = logger.with([ + Attr.of("requestId", req.id), + Attr.of("user", req.userId), +]); + +requestLog.info("payment processed", [ + Attr.of("amount", amount), + Attr.of("currency", currency), +]); +``` + +There are no markers and no MDC in the base shape. Categorisation, errors, and +structured payload all travel as `Attr` values. Request context is normally explicit +(`logger.with([...])`), with `LoggerContext` available when framework code needs +implicit propagation. Backends implement the small `Handler` interface. + +## Documentation + +Start with the repo-level docs: + +- [`doc/logging/README.md`](../doc/logging/README.md) — start-here guide and reading paths +- [`doc/logging/api-cross-reference.md`](../doc/logging/api-cross-reference.md) — official Go + `log/slog` links mapped to each Ecstasy type and the local differences +- [`doc/logging/lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) — + side-by-side design comparison and reviewer questions +- [`doc/logging/usage/slog-parity.md`](../doc/logging/usage/slog-parity.md) — Go `log/slog` + method/type mapping and intentional Ecstasy differences +- [`doc/logging/usage/custom-handlers.md`](../doc/logging/usage/custom-handlers.md) — guide to + writing custom handlers +- [`doc/logging/cloud-integration.md`](../doc/logging/cloud-integration.md) — why both + shapes map cleanly to cloud logging systems + +The source files under [`src/main/x/slogging/`](src/main/x/slogging/) are intentionally +small and each names its Go `log/slog` counterpart in the doc comment. diff --git a/lib_slogging/build.gradle.kts b/lib_slogging/build.gradle.kts index 09b9e8bdee..5f2bcc475a 100644 --- a/lib_slogging/build.gradle.kts +++ b/lib_slogging/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { xdkJavaTools(libs.javatools) xtcModule(libs.xdk.ecstasy) + xtcModule(libs.xdk.json) xtcModuleTest(libs.javatools.bridge) xtcModuleTest(libs.xdk.xunit.engine) } diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index c393d4a87f..aeb7a882dc 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -7,10 +7,9 @@ * Ecstasy structured-logging library, slog-shaped. * * The module is intentionally shaped to be _instantly familiar_ to anyone who has used - * `log/slog` in Go: a top-level `Logger` carrying a `Handler` and a list of attached - * `Attr`s, derived loggers via `Logger.with(attrs)`, no thread-local context (you carry - * a logger), no "marker" concept (categorisation is just attributes), open-ended - * integer-based `Level` values. + * `log/slog` in Go: a top-level `Logger` carrying a `Handler`, derived loggers via + * `Logger.with(attrs)`, optional context propagation via `LoggerContext`, no "marker" + * concept (categorisation is just attributes), open-ended integer-based `Level` values. * * The primary entry point is injection: * @@ -29,11 +28,9 @@ * * # Status * - * **This module is currently a skeleton** — interfaces, the `Logger` const, and stub - * handlers (`TextHandler`, `NopHandler`, `MemoryHandler`, `JSONHandler`). Full - * implementation is gated on reviewer feedback in - * `doc/logging/lib-logging-vs-lib-slogging.md` so we don't sink effort into both shapes - * before deciding which one Ecstasy should adopt. + * **This module is a working comparison POC.** The core API, level checks, derived + * loggers, groups, custom levels, runtime injection, source metadata, context binding, + * and memory/text/JSON/no-op handlers are implemented and covered by unit tests. * * # API / Implementation boundary * @@ -43,18 +40,22 @@ * - [Attr] — single key/value pair carried as structured data * - [Record] — immutable record of a single log call (LogEvent equivalent) * - [Handler] — the SPI that backends implement (LogSink equivalent) + * - [LoggerContext] — optional SharedContext helper for request-scoped loggers * * The implementation side of that boundary contains: * - [TextHandler] — default human-readable text handler, writes via `@Inject Console` - * - [JSONHandler] — JSON-Lines structured handler (skeleton) + * - [JSONHandler] — JSON-Lines structured handler, rendered by `lib_json` + * - [BoundHandler] — derivation wrapper used by handlers that do not cache prefixes * - [NopHandler] — drops every record * - [MemoryHandler] — captures records in memory; useful in tests * * # See also * * doc/logging/lib-logging-vs-lib-slogging.md — the design comparison document + * doc/logging/api-cross-reference.md — official Go slog links mapped to these types * doc/logging/open-questions.md — list of reviewer questions (Q-D6) * lib_logging/src/main/x/logging.x — the SLF4J-shaped sibling library */ module slogging.xtclang.org { + package json import json.xtclang.org; } diff --git a/lib_slogging/src/main/x/slogging/Attr.x b/lib_slogging/src/main/x/slogging/Attr.x index 254c49cb57..6eed99e3d9 100644 --- a/lib_slogging/src/main/x/slogging/Attr.x +++ b/lib_slogging/src/main/x/slogging/Attr.x @@ -3,9 +3,9 @@ * for structured data attached to a log record. * * Every piece of structured data — what SLF4J would split across `arguments`, `marker`, - * and `keyValues` — is expressed as one of these. A `Logger` carries an `Attr[]` of - * "always include these" attributes; the per-call `info(message, extra)` adds more for - * that call only. + * and `keyValues` — is expressed as one of these. `Logger.with(...)` asks the handler + * to derive a new handler with "always include these" attributes; the per-call + * `info(message, extra)` adds more for that call only. * * Attr.of("user", "alice") * Attr.of("count", 42) diff --git a/lib_slogging/src/main/x/slogging/BoundHandler.x b/lib_slogging/src/main/x/slogging/BoundHandler.x new file mode 100644 index 0000000000..4c8fd5590a --- /dev/null +++ b/lib_slogging/src/main/x/slogging/BoundHandler.x @@ -0,0 +1,100 @@ +/** + * Handler derivation wrapper used by shipped handlers that do not maintain their own + * cached prefix representation. + * + * Go slog puts the power of `Logger.With` and `Logger.WithGroup` at the handler + * boundary: a derived logger contains a derived handler. A production handler can + * override [withAttrs] / [withGroup] to pre-render JSON fragments, allocate a cached text + * prefix, or fold attributes into a backend-specific context object. `BoundHandler` + * supplies the default implementation for handlers that only need correct semantics. + * + * The wrapper composes in the same order as Go slog: + * + * logger.with([Attr.of("env", "prod")]) + * .withGroup("payments") + * .info("charged", [Attr.of("amount", 1099)]); + * + * yields `env=prod` outside the `payments` group, while: + * + * logger.withGroup("payments") + * .with([Attr.of("env", "prod")]) + * .info("charged", [Attr.of("amount", 1099)]); + * + * yields both `env` and `amount` inside the `payments` group. + */ +const BoundHandler(Handler delegate, Attr[] attrs, String? groupName) + implements Handler { + + /** + * Derive a handler with pre-bound attributes. + */ + construct(Handler delegate, Attr[] attrs) { + construct BoundHandler(delegate, attrs.toArray(Constant), Null); + } + + /** + * Derive a handler that groups subsequent attributes under `groupName`. + */ + construct(Handler delegate, String groupName) { + construct BoundHandler(delegate, [], groupName); + } + + /** + * Fast-path level check. Bound attrs and groups never affect enablement. + */ + @Override + Boolean enabled(Level level) = delegate.enabled(level); + + /** + * Apply the bound attrs/group to the record, then forward to the delegate. + */ + @Override + void handle(Record record) { + Attr[] merged = merge(attrs, record.attrs); + if (String group ?= groupName) { + merged = merged.empty + ? [] + : [Attr.group(group, merged)].toArray(Constant); + } + + delegate.handle(new Record( + time = record.time, + message = record.message, + level = record.level, + attrs = merged, + exception = record.exception, + sourceFile = record.sourceFile, + sourceLine = record.sourceLine, + threadName = record.threadName, + )); + } + + /** + * Stack another attribute derivation around this handler. The wrapper order is what + * preserves the difference between `With(...).WithGroup(...)` and + * `WithGroup(...).With(...)`. + */ + @Override + Handler withAttrs(Attr[] more) { + return more.empty ? this : new BoundHandler(this, more); + } + + /** + * Stack another group derivation around this handler. + */ + @Override + Handler withGroup(String name) { + return name == "" ? this : new BoundHandler(this, name); + } + + /** + * Immutable concatenation helper for passable records. + */ + private Attr[] merge(Attr[] first, Attr[] second) { + return first.empty + ? second.toArray(Constant) + : second.empty + ? first.toArray(Constant) + : (first + second).toArray(Constant); + } +} diff --git a/lib_slogging/src/main/x/slogging/Handler.x b/lib_slogging/src/main/x/slogging/Handler.x index de5cd64f9c..1f70e93713 100644 --- a/lib_slogging/src/main/x/slogging/Handler.x +++ b/lib_slogging/src/main/x/slogging/Handler.x @@ -16,9 +16,10 @@ * "merge attrs into the namespace" work *once* at derivation time rather than on every * `handle` call. * - * Stateless / forwarder handlers — `TextHandler`, `NopHandler` — return `this` from - * both derivation methods. Only handlers that actually pre-resolve attrs need a richer - * implementation. + * Handlers that do not maintain their own derived representation can return + * [BoundHandler] from both derivation methods. Handlers with a native prefix/cache can + * return their own implementation instead. A true discard handler such as [NopHandler] + * can still return `this`. * * # Choosing between `const` and `service` for an implementation * @@ -52,15 +53,15 @@ interface Handler { void handle(Record record); /** - * Return a handler that carries the supplied attributes pre-bound. Stateless - * handlers can return `this`; structured handlers may pre-render the attrs into a - * cached prefix. + * Return a handler that carries the supplied attributes pre-bound. Use + * [BoundHandler] for the default semantics, or return a handler-specific derived + * instance that caches a rendered prefix. */ Handler withAttrs(Attr[] attrs); /** * Return a handler that namespaces subsequent attributes under the supplied group - * name. Stateless handlers can return `this`. + * name. Use [BoundHandler] for the default semantics. */ Handler withGroup(String name); } diff --git a/lib_slogging/src/main/x/slogging/JSONHandler.x b/lib_slogging/src/main/x/slogging/JSONHandler.x index 8383c74f00..284425778b 100644 --- a/lib_slogging/src/main/x/slogging/JSONHandler.x +++ b/lib_slogging/src/main/x/slogging/JSONHandler.x @@ -1,12 +1,15 @@ +import json.Doc; +import json.JsonObject; +import json.Printer; + /** * Corresponds to `log/slog.JSONHandler` (`go.dev/src/log/slog/json_handler.go`). The - * structured-output handler — emits one JSON object per record on its own line. - * - * # Skeleton status + * structured-output handler — emits one compact JSON object per record on its own line. * - * Stub implementation only — emits a tiny hand-rolled JSON, not RFC-compliant. Real - * implementation will use `lib_json` once we wire it as a dependency. Listed here so - * the comparison document can refer to it as a real type, not just a future name. + * Rendering is delegated to `lib_json`, so strings are escaped by the platform JSON + * printer rather than by ad-hoc concatenation. Groups are preserved as nested objects, + * source metadata is emitted under `"source"`, and exceptions are represented + * structurally. */ const JSONHandler(Level rootLevel, String groupPrefix) implements Handler { @@ -27,39 +30,153 @@ const JSONHandler(Level rootLevel, String groupPrefix) @Inject Console console; + /** + * Cheap threshold check; no JSON work happens for disabled records. + */ @Override Boolean enabled(Level level) { return level.severity >= rootLevel.severity; } + /** + * Render and print one JSON line. + */ @Override void handle(Record record) { - // TODO(impl): use lib_json. The skeleton here renders a flat, NOT-rfc-compliant - // approximation so the comparison document has something to point at. The - // real implementation will: - // - properly escape strings; - // - nest groups instead of dot-flattening; - // - render timestamps as RFC3339; - // - render exception traces structurally. - StringBuffer buf = new StringBuffer(); - buf.append("{\"time\":\"").append(record.time.toString()).append("\"") - .append(",\"level\":\"").append(record.level.label).append("\"") - .append(",\"msg\":\"").append(record.message).append("\""); - for (Attr a : record.attrs) { - buf.append(",\""); - String key = groupPrefix == "" ? a.key : $"{groupPrefix}.{a.key}"; - buf.append(key).append("\":\"").append(a.value.toString()).append("\""); + console.print(render(record)); + } + + /** + * Render a record to a compact JSON string. Exposed so tests and custom handlers can + * verify the JSON shape without intercepting `Console`. + */ + String render(Record record) { + return Printer.DEFAULT.render(toJson(record)); + } + + /** + * Convert a record into a JSON document. This is the handler's semantic core; `handle` + * only prints the rendered document. + */ + JsonObject toJson(Record record) { + JsonObject obj = json.newObject(); + obj.put("time", record.time.toString()); + obj.put("level", record.level.label); + obj.put("msg", record.message); + + JsonObject attrTarget = groupPrefix == "" ? obj : ensureObject(obj, groupPrefix); + addAttrs(attrTarget, record.attrs); + + if (Exception e ?= record.exception) { + obj.put("exception", exceptionJson(e)); + } + + if (String file ?= record.sourceFile) { + JsonObject source = json.newObject(); + source.put("file", file); + if (record.sourceLine >= 0) { + source.put("line", record.sourceLine.toIntLiteral()); + } + obj.put("source", source.makeImmutable()); } - buf.append("}"); - console.print(buf.toString()); + + if (record.threadName != "") { + obj.put("thread", record.threadName); + } + + return obj.makeImmutable(); } + /** + * Return a handler with pre-bound attrs. This implementation uses [BoundHandler]; + * a lower-level production sink could override this method to cache a serialized + * prefix instead. + */ @Override - Handler withAttrs(Attr[] attrs) = this; + Handler withAttrs(Attr[] attrs) { + return attrs.empty ? this : new BoundHandler(this, attrs); + } + /** + * Return a handler that nests subsequent attrs under `name`. + */ @Override Handler withGroup(String name) { - String combined = groupPrefix == "" ? name : $"{groupPrefix}.{name}"; - return new JSONHandler(rootLevel, combined); + return name == "" ? this : new BoundHandler(this, name); + } + + /** + * Add all attrs into a JSON object, preserving slog groups as nested objects. + */ + private void addAttrs(JsonObject obj, Attr[] attrs) { + for (Attr a : attrs) { + obj.put(a.key, attrValue(a.value)); + } + } + + /** + * Convert an attribute value to a JSON document. + */ + private Doc attrValue(Object value) { + if (String s := value.is(String)) { + return s; + } + if (Boolean b := value.is(Boolean)) { + return b; + } + if (IntLiteral n := value.is(IntLiteral)) { + return n; + } + if (FPLiteral n := value.is(FPLiteral)) { + return n; + } + if (IntNumber n := value.is(IntNumber)) { + return n.toIntLiteral(); + } + if (FPNumber n := value.is(FPNumber)) { + return n.toFPLiteral(); + } + if (Attr[] attrs := value.is(Attr[])) { + JsonObject nested = json.newObject(); + addAttrs(nested, attrs); + return nested.makeImmutable(); + } + if (Exception e := value.is(Exception)) { + return exceptionJson(e); + } + + return value.toString(); + } + + /** + * Represent an exception structurally. Stack frames are intentionally omitted from + * the POC because `Exception.formatStackTrace()` is still a TODO in `lib_ecstasy`. + */ + private JsonObject exceptionJson(Exception e) { + JsonObject obj = json.newObject(); + obj.put("type", &e.class.name); + obj.put("message", e.message); + if (Exception cause ?= e.cause) { + obj.put("cause", exceptionJson(cause)); + } + return obj.makeImmutable(); + } + + /** + * Ensure a nested object path exists. Used only for the compatibility `groupPrefix` + * constructor; normal grouping flows through [BoundHandler] as `Attr.group(...)`. + */ + private JsonObject ensureObject(JsonObject root, String path) { + JsonObject target = root; + for (String part : path.split('.')) { + if (Doc existing := target.get(part), existing.is(JsonObject)) { + target = existing.as(JsonObject); + } else { + JsonObject child = json.newObject(); + target.put(part, child); + target = child; + } + } + return target; } } diff --git a/lib_slogging/src/main/x/slogging/Level.x b/lib_slogging/src/main/x/slogging/Level.x index 151227b1aa..dac0cc7b0c 100644 --- a/lib_slogging/src/main/x/slogging/Level.x +++ b/lib_slogging/src/main/x/slogging/Level.x @@ -39,11 +39,17 @@ const Level(Int severity, String label) return severity >= threshold.severity; } + /** + * Natural ordering is severity ordering, matching Go slog's integer-level model. + */ @Override static Ordered compare(CompileType a, CompileType b) { return a.severity <=> b.severity; } + /** + * Human-readable level label used by text/JSON handlers. + */ @Override String toString() = label; } diff --git a/lib_slogging/src/main/x/slogging/Logger.x b/lib_slogging/src/main/x/slogging/Logger.x index 7a746cf9b6..4e6860902e 100644 --- a/lib_slogging/src/main/x/slogging/Logger.x +++ b/lib_slogging/src/main/x/slogging/Logger.x @@ -9,7 +9,7 @@ * * `Logger` is a `const` so: * - methods run on the caller's fiber (no service-boundary hop on the hot path); - * - derivation via [with] / [withGroup] is just construction, no mutation; + * - derivation via [with] / [withGroup] is just handler construction, no mutation; * - it is `Passable`, so it can flow across fibers (fields of services, parameters * of service calls). * @@ -31,15 +31,15 @@ * // text handler: 2026-04-29 INFO charged payments.amount=1099 * // json handler: {"level":"INFO","msg":"charged","payments":{"amount":1099}} * - * # Skeleton status + * # POC status * - * The `log` method is implemented (level-check fast path, attr concatenation, record - * construction, forward to handler). Source-location capture is not. There is no - * `LogAttrs(ctx, level, msg, attrs...)` form yet — the per-level methods cover the same - * ground in v0; `ctx`-style propagation is the open Q-D6.b discussion in - * `open-questions.md`. + * The `log` method is implemented (level-check fast path, record construction, forward + * to handler), and [logAt] provides explicit source metadata for callers or future + * compiler/runtime sugar. Runtime injection for `@Inject slogging.Logger logger;` is + * registered by `NativeContainer`. There is no `LogAttrs(ctx, level, msg, attrs...)` + * form; [LoggerContext] is the Ecstasy-shaped optional context helper. */ -const Logger(Handler handler, Attr[] attrs) +const Logger(Handler handler) implements Orderable { @Inject Clock clock; @@ -56,70 +56,146 @@ const Logger(Handler handler, Attr[] attrs) * the shorter form. See `lib_logging.BasicLogger`. */ construct() { - construct Logger(new TextHandler(), []); + construct Logger(new TextHandler()); } /** - * Convenience: handler only, empty attrs. + * Compatibility convenience: handler plus pre-bound attrs. The attrs are handed to + * [Handler.withAttrs] immediately, which is the slog contract and what allows a + * handler to pre-render or cache its own derived state. */ - construct(Handler handler) { - construct Logger(handler, []); + construct(Handler handler, Attr[] attrs) { + construct Logger(attrs.empty ? handler : handler.withAttrs(attrs)); } + /** + * Cheap `Debug` enabled check. Mirrors Go's `Logger.Enabled(ctx, slog.LevelDebug)` + * and the SLF4J-shaped library's `debugEnabled` property. + */ @RO Boolean debugEnabled.get() = handler.enabled(Level.Debug); + + /** + * Cheap `Info` enabled check. Use before constructing expensive attributes. + */ @RO Boolean infoEnabled.get() = handler.enabled(Level.Info); + + /** + * Cheap `Warn` enabled check. Delegates directly to the active handler. + */ @RO Boolean warnEnabled.get() = handler.enabled(Level.Warn); + + /** + * Cheap `Error` enabled check. Delegates directly to the active handler. + */ @RO Boolean errorEnabled.get() = handler.enabled(Level.Error); + /** + * Runtime-level enabled check for custom levels. Equivalent to Go + * `Logger.Enabled(ctx, level)`, minus the `context.Context` parameter. + */ Boolean enabled(Level level) = handler.enabled(level); + /** + * Emit a `Debug` record. The message is already a complete human-readable string; + * structured values belong in `extra` attrs rather than `{}` placeholders. See + * `doc/logging/usage/slog-parity.md` § "Message formatting". + */ void debug(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Debug, message, extra, cause); + /** + * Emit an `Info` record. + */ void info(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Info, message, extra, cause); + /** + * Emit a `Warn` record. + */ void warn(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Warn, message, extra, cause); + /** + * Emit an `Error` record. `cause` is an Ecstasy convenience; the Go slog idiom is + * usually `slog.Any("err", err)`. Either form can be rendered by a handler. + */ void error(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Error, message, extra, cause); /** * Open-level emission. Use for custom levels. + * + * The enabled check is first. If the handler rejects `level`, no record is + * constructed and no attrs are merged. This mirrors Go slog's + * `Handler.Enabled` fast path. */ void log(Level level, String message, Attr[] extra = [], Exception? cause = Null) { + emit(level, message, extra, cause, Null, -1); + } + + /** + * Open-level emission with explicit source metadata. + * + * Go `slog` can populate source information when configured with `AddSource`. This + * POC cannot ask the compiler/runtime for the current call site yet, so `logAt` + * provides a precise and testable explicit API. A later compiler helper can lower + * caller-site sugar into this method without changing the handler contract. + */ + void logAt(Level level, String message, String sourceFile, Int sourceLine, + Attr[] extra = [], Exception? cause = Null) { + emit(level, message, extra, cause, sourceFile, sourceLine); + } + + /** + * Common emission path used by [log] and [logAt]. The handler owns any attributes + * attached by derived loggers; the record receives only call-time extras. + */ + private void emit(Level level, String message, Attr[] extra, Exception? cause, + String? sourceFile, Int sourceLine) { if (!handler.enabled(level)) { return; } - Attr[] merged = extra.empty ? attrs : (attrs + extra).toArray(Constant); handler.handle(new Record( - time = clock.now, - message = message, - level = level, - attrs = merged, - exception = cause, + time = clock.now, + message = message, + level = level, + attrs = extra.toArray(Constant), + exception = cause, + sourceFile = sourceFile, + sourceLine = sourceLine, )); } /** - * Return a derived logger that carries the supplied attributes plus this one's. - * Equivalent to `slog.Logger.With(...)`. The handler is also asked to derive - * (`handler.withAttrs(more)`) so structured handlers can pre-resolve. + * Return a derived logger that carries the supplied attributes. Equivalent to + * `slog.Logger.With(...)`. + * + * The attrs are not stored in `Logger`; they live in the derived [Handler]. That is + * the key slog design point: a handler can pre-resolve attrs once at derivation time + * instead of merging or rendering them on every emission. + * + * This is the slog replacement for SLF4J MDC in the core API: instead of hidden + * fiber-local state, code passes around a logger that visibly carries request or + * component attrs. See `doc/logging/usage/slog-parity.md`. */ Logger with(Attr[] more) { if (more.empty) { return this; } - Attr[] combined = attrs.empty ? more.toArray(Constant) : (attrs + more).toArray(Constant); - return new Logger(handler.withAttrs(more), combined); + return new Logger(handler.withAttrs(more)); } /** * Return a derived logger whose subsequent attributes are namespaced under * `groupName`. Equivalent to `slog.Logger.WithGroup(...)`. + * + * The grouping state lives in the derived handler. Text handlers dot-flatten + * (`payments.amount`), while JSON handlers nest (`{"payments":{"amount":...}}`). */ Logger withGroup(String groupName) { - return new Logger(handler.withGroup(groupName), attrs); + if (groupName == "") { + return this; + } + return new Logger(handler.withGroup(groupName)); } } diff --git a/lib_slogging/src/main/x/slogging/LoggerContext.x b/lib_slogging/src/main/x/slogging/LoggerContext.x new file mode 100644 index 0000000000..41201a7d2c --- /dev/null +++ b/lib_slogging/src/main/x/slogging/LoggerContext.x @@ -0,0 +1,53 @@ +import ecstasy.SharedContext; + +/** + * Optional context helper for code that wants Go-slog-style request propagation without + * passing a logger through every method signature. + * + * Go slog accepts `context.Context` on `LogAttrs` and `Handler.Enabled` / `Handle`. + * Ecstasy does not need to copy that exact API to be familiar: [SharedContext] already + * models a logical execution context that flows across service calls and child fibers. + * `LoggerContext` is the smallest explicit bridge: + * + * Logger requestLog = logger.with([Attr.of("requestId", id)]); + * using (loggerContext.bind(requestLog)) { + * worker.process^(); + * } + * + * if (Logger log := loggerContext.current()) { + * log.info("inside worker"); + * } + * + * This helper is intentionally separate from [Logger]. The recommended default remains + * explicit logger passing; `LoggerContext` is available for frameworks, request + * dispatchers, and adapter layers where implicit context is the least noisy API. + */ +const LoggerContext { + + /** + * The fiber-local backing store. No default value is supplied, so [current] is + * conditional and callers must decide their fallback policy explicitly. + */ + private static SharedContext context = new SharedContext("slogging.Logger"); + + /** + * Bind `logger` to the current logical execution context. + * + * Use the returned token with `using` so the previous logger is restored reliably. + */ + SharedContext.Token bind(Logger logger) { + return context.withValue(logger); + } + + /** + * Return the context logger when one is bound. + */ + conditional Logger current() = context.hasValue(); + + /** + * Return the context logger, or `fallback` when no context logger is bound. + */ + Logger currentOr(Logger fallback) { + return context.hasValue() ?: fallback; + } +} diff --git a/lib_slogging/src/main/x/slogging/MemoryHandler.x b/lib_slogging/src/main/x/slogging/MemoryHandler.x index da561ee8dd..799d81f463 100644 --- a/lib_slogging/src/main/x/slogging/MemoryHandler.x +++ b/lib_slogging/src/main/x/slogging/MemoryHandler.x @@ -35,25 +35,40 @@ service MemoryHandler return recordList.toArray(Constant); } + /** + * Cheap threshold check. Defaults to `Debug`, matching Go slog's lowest canonical + * level and making tests capture everything unless they opt into a stricter level. + */ @Override Boolean enabled(Level level) { return level.severity >= rootLevel.severity; } + /** + * Capture the record in memory. This is the slog-shaped analogue of Logback's + * `ListAppender` and `lib_logging.MemoryLogSink`. + */ @Override void handle(Record record) { recordList.add(record); } + /** + * Derived loggers share this capture buffer, but [BoundHandler] applies the + * pre-bound attrs before the record reaches [handle]. + */ @Override Handler withAttrs(Attr[] attrs) { - // Share the same underlying recordList so derived loggers' records show up in - // the same place — matches slogtest behaviour and what tests intuitively want. - return this; + return attrs.empty ? this : new BoundHandler(this, attrs); } + /** + * Group derived records while keeping the same capture buffer. + */ @Override - Handler withGroup(String name) = this; + Handler withGroup(String name) { + return name == "" ? this : new BoundHandler(this, name); + } /** * Discard all captured records. diff --git a/lib_slogging/src/main/x/slogging/NopHandler.x b/lib_slogging/src/main/x/slogging/NopHandler.x index cb74d52376..44edbcade8 100644 --- a/lib_slogging/src/main/x/slogging/NopHandler.x +++ b/lib_slogging/src/main/x/slogging/NopHandler.x @@ -14,8 +14,28 @@ const NopHandler implements Handler { - @Override Boolean enabled(Level level) = False; - @Override void handle(Record record) {} - @Override Handler withAttrs(Attr[] attrs) = this; - @Override Handler withGroup(String name) = this; + /** + * Always disabled so caller-side fast paths skip record construction. + */ + @Override + Boolean enabled(Level level) = False; + + /** + * Deliberately drops the record. In normal use this should not be reached because + * `enabled` returns `False`, but it remains a no-op for defensive simplicity. + */ + @Override + void handle(Record record) {} + + /** + * Deriving a no-op handler is still no-op. + */ + @Override + Handler withAttrs(Attr[] attrs) = this; + + /** + * Grouping a no-op handler is still no-op. + */ + @Override + Handler withGroup(String name) = this; } diff --git a/lib_slogging/src/main/x/slogging/Record.x b/lib_slogging/src/main/x/slogging/Record.x index 98b1dac898..da8eb1714c 100644 --- a/lib_slogging/src/main/x/slogging/Record.x +++ b/lib_slogging/src/main/x/slogging/Record.x @@ -6,21 +6,28 @@ * no separate `marker` or `keyValues` — categorisation and structured fields all live * in `attrs`. * - * The `attrs` array carries both the "always-on" attributes attached to the source - * `Logger` (via `Logger.with(...)`) and the per-call extras passed to `info(...)` etc., - * concatenated by the `Logger`. Handlers see one flat list. + * The `attrs` array carries the structured data visible to the current handler call. + * Derived handlers may prepend attrs or wrap them in groups before forwarding to the + * final backend. This matches Go slog's `Handler.WithAttrs` / `WithGroup` model. * * Source-location capture (`sourceFile`, `sourceLine`) is opt-in. The default is - * `(Null, -1)`, meaning "not captured." A future `Logger` configuration switch will - * turn it on. + * `(Null, -1)`, meaning "not captured." [Logger.logAt] populates these fields + * explicitly; future compiler/runtime sugar can lower call-site metadata into that API. */ const Record( + // Time at which the logger accepted the record. Captured before calling the handler. Time time, + // Completed human-readable message. Variable data should normally live in attrs. String message, + // Open integer severity. Level level, + // Structured data after any handler derivation has been applied. Attr[] attrs, + // Ecstasy-specific convenience for a thrown exception/cause. Exception? exception = Null, + // Optional source metadata. String? sourceFile = Null, Int sourceLine = -1, + // Placeholder for future fiber/thread identity support. String threadName = "", ); diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index d0c678134b..0a16a2b0b1 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -8,14 +8,12 @@ * * If the record carries an exception, it is rendered on the following line. * - * # Skeleton status + * # POC status * * The implementation here is intentionally minimal — enough to demonstrate the slog * shape end to end (`Logger.with(...)`, attribute folding into namespaced keys, level - * threshold filtering) but not yet a production formatter. Specifically, no escaping - * of `=` / `"` in attribute values, no group nesting collapse beyond one level, no - * timestamp formatting options. Tracked in `open-questions.md` § "lib_slogging work - * not yet implemented" once the parity table is added. + * threshold filtering) but not yet a fully configurable formatter. Specifically, it has + * fixed timestamp/message formatting and no handler options object yet. * * # Why this handler is a `const`, not a `service` * @@ -44,11 +42,20 @@ const TextHandler(Level rootLevel, String groupPrefix) @Inject Console console; + /** + * Cheap threshold check. This is intentionally only arithmetic on the level's + * severity; no formatting or attribute walking belongs on the fast path. + */ @Override Boolean enabled(Level level) { return level.severity >= rootLevel.severity; } + /** + * Render a record as one line of text. This is the POC equivalent of Go + * `slog.TextHandler.Handle`: the message is quoted and attrs are appended as + * `key=value` pairs. + */ @Override void handle(Record record) { StringBuffer buf = new StringBuffer(); @@ -74,16 +81,12 @@ const TextHandler(Level rootLevel, String groupPrefix) @Override Handler withAttrs(Attr[] attrs) { - // Stateless variant: the Logger already carries the attrs list and merges them - // into every record before calling handle(). A richer handler would pre-resolve - // here. - return this; + return attrs.empty ? this : new BoundHandler(this, attrs); } @Override Handler withGroup(String name) { - String combined = groupPrefix == "" ? name : $"{groupPrefix}.{name}"; - return new TextHandler(rootLevel, combined); + return name == "" ? this : new BoundHandler(this, name); } /** @@ -107,5 +110,9 @@ const TextHandler(Level rootLevel, String groupPrefix) } } + /** + * POC string quoting. Production text output should escape quotes, newlines, and + * separators; that belongs with the production handler, not the API sketch. + */ private String quote(String s) = $"\"{s}\""; } diff --git a/lib_slogging/src/test/x/SLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest.x index 57f169e0af..457eae8c60 100644 --- a/lib_slogging/src/test/x/SLoggingTest.x +++ b/lib_slogging/src/test/x/SLoggingTest.x @@ -11,10 +11,15 @@ * - exceptions arrive at the handler intact; * - `Logger.with(...)` accumulates always-on attributes that flow into every record; * - `Logger.withGroup(...)` namespaces subsequent attributes; + * - `Logger.logAt(...)` populates explicit source metadata; + * - `LoggerContext` propagates request-scoped loggers; + * - `JSONHandler` renders parseable `lib_json` documents; + * - `HandlerContract` checks `withAttrs` / `withGroup` conformance; * - `Attr.group(name, [...])` renders nested structure; * - custom `Level` values comparable to / between the canonical four work. */ module SLoggingTest { + package json import json.xtclang.org; package slogging import slogging.xtclang.org; package xunit import xunit.xtclang.org; } diff --git a/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x b/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x new file mode 100644 index 0000000000..4993c8726f --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x @@ -0,0 +1,38 @@ +import slogging.Handler; +import slogging.Attr; +import slogging.Level; +import slogging.Record; + +/** + * Test-only handler mirroring `LoggingTest.CountingSink`: counts records by level. + * This proves that a user-defined slog handler can replace the shipped handlers without + * changing caller code. + */ +service CountingHandler + implements Handler { + + public/private Map counts = new HashMap(); + + @Override + Boolean enabled(Level level) { + return True; + } + + @Override + void handle(Record record) { + counts.process(record.level, e -> { + e.value = e.exists ? e.value + 1 : 1; + return Null; + }); + } + + @Override + Handler withAttrs(Attr[] attrs) { + return attrs.empty ? this : new slogging.BoundHandler(this, attrs); + } + + @Override + Handler withGroup(String name) { + return name == "" ? this : new slogging.BoundHandler(this, name); + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/CountingHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/CountingHandlerTest.x new file mode 100644 index 0000000000..a6ed7ae5a8 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/CountingHandlerTest.x @@ -0,0 +1,24 @@ +import slogging.Level; +import slogging.Logger; + +/** + * Demonstrates that a custom slog `Handler` can be plugged in and observed + * end-to-end, parallel to `LoggingTest.CountingSinkTest`. + */ +class CountingHandlerTest { + + @Test + void shouldCountPerLevel() { + CountingHandler handler = new CountingHandler(); + Logger logger = new Logger(handler); + + logger.info ("a"); + logger.info ("b"); + logger.warn ("c"); + logger.error("d"); + + assert handler.counts[Level.Info] == 2; + assert handler.counts[Level.Warn] == 1; + assert handler.counts[Level.Error] == 1; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x b/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x new file mode 100644 index 0000000000..6da6a5ede9 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x @@ -0,0 +1,60 @@ +import slogging.Attr; +import slogging.Handler; +import slogging.Level; +import slogging.Record; + +/** + * Minimal slogtest-style contract helpers for third-party handlers. + * + * Go ships `testing/slogtest` so backend authors can prove that `WithAttrs` and + * `WithGroup` are implemented correctly. This helper is the Ecstasy POC equivalent: + * pass a handler and a snapshot function for the records it emitted. + */ +class HandlerContract { + + /** + * Verify that `withAttrs` prepends bound attributes to call-time attributes. + */ + static void assertWithAttrsPrepend(Handler root, function Record[] () records) { + Handler derived = root.withAttrs([Attr.of("requestId", "r_1")]); + derived.handle(sample([Attr.of("path", "/checkout")])); + + Record[] captured = records(); + assert captured.size == 1; + assert captured[0].attrs.size == 2; + assert captured[0].attrs[0].key == "requestId"; + assert captured[0].attrs[0].value == "r_1"; + assert captured[0].attrs[1].key == "path"; + } + + /** + * Verify that `withGroup` nests subsequent attrs under the group name. + */ + static void assertWithGroupNests(Handler root, function Record[] () records) { + Handler grouped = root.withGroup("payments"); + grouped.handle(sample([Attr.of("amount", 1099)])); + + Record[] captured = records(); + assert captured.size == 1; + assert captured[0].attrs.size == 1; + assert captured[0].attrs[0].key == "payments"; + assert captured[0].attrs[0].value.is(Attr[]); + + Attr[] children = captured[0].attrs[0].value.as(Attr[]); + assert children.size == 1; + assert children[0].key == "amount"; + assert children[0].value == 1099; + } + + /** + * Shared sample record. + */ + private static Record sample(Attr[] attrs) { + return new Record( + time = new Time("2019-05-22T120123.456Z"), + message = "event", + level = Level.Info, + attrs = attrs, + ); + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x b/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x new file mode 100644 index 0000000000..e1952e539b --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x @@ -0,0 +1,19 @@ +import slogging.MemoryHandler; + +/** + * Applies the contract helper to the shipped memory handler. Third-party handlers can + * copy this pattern for their own tests. + */ +class HandlerContractTest { + + @Test + void shouldValidateMemoryHandlerDerivations() { + MemoryHandler handler = new MemoryHandler(); + + HandlerContract.assertWithAttrsPrepend(handler, () -> handler.records); + + handler.reset(); + + HandlerContract.assertWithGroupNests(handler, () -> handler.records); + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x b/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x new file mode 100644 index 0000000000..b5b4cb1d5a --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x @@ -0,0 +1,39 @@ +import slogging.Attr; +import slogging.Logger; + +/** + * Tests that `Logger.with(...)` and `Logger.withGroup(...)` call into the handler + * derivation hooks. That hook is the main extra power in the slog handler contract + * compared with the two-method `LogSink` contract. + */ +class HandlerDerivationTest { + + @Test + void shouldCallHandlerWithAttrsWhenDerivingLogger() { + TrackingHandler handler = new TrackingHandler(); + Logger base = new Logger(handler); + + Logger derived = base.with([Attr.of("requestId", "r_1")]); + derived.info("processing"); + + assert handler.withAttrsCalls == 1; + assert handler.lastAttrs.size == 1; + assert handler.lastAttrs[0].key == "requestId"; + assert handler.lastAttrs[0].value == "r_1"; + assert handler.records.size == 1; + } + + @Test + void shouldCallHandlerWithGroupWhenGroupingLogger() { + TrackingHandler handler = new TrackingHandler(); + Logger base = new Logger(handler); + + Logger grouped = base.withGroup("payments"); + grouped.info("charged", [Attr.of("amount", 1099)]); + + assert handler.withGroupCalls == 1; + assert handler.groups.size == 1; + assert handler.groups[0] == "payments"; + assert handler.records.size == 1; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x new file mode 100644 index 0000000000..e3af3451f0 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x @@ -0,0 +1,75 @@ +import json.Doc; +import json.JsonObject; +import json.Parser; + +import slogging.Attr; +import slogging.JSONHandler; +import slogging.Level; +import slogging.Record; + +/** + * Tests the shipped JSON handler's document shape. This is intentionally stricter than + * string contains checks: the rendered output must parse through `lib_json`. + */ +class JSONHandlerTest { + + @Test + void shouldRenderParseableJsonWithEscapingAndNestedGroups() { + JSONHandler handler = new JSONHandler(); + Exception boom = new Exception("bad \"card\""); + Record record = new Record( + time = new Time("2019-05-22T120123.456Z"), + message = "charged \"ok\"", + level = Level.Info, + attrs = [ + Attr.of("amount", 1099), + Attr.group("user", [Attr.of("id", "u_3")]), + ], + exception = boom, + sourceFile = "PaymentService.x", + sourceLine = 42, + ); + + JsonObject obj = parseObject(handler.render(record)); + + assert obj["level"] == "INFO"; + assert obj["msg"] == "charged \"ok\""; + assert obj["amount"] == 1099; + + assert obj["user"].is(JsonObject); + JsonObject user = obj["user"].as(JsonObject); + assert user["id"] == "u_3"; + + assert obj["exception"].is(JsonObject); + JsonObject exception = obj["exception"].as(JsonObject); + assert exception["message"] == "bad \"card\""; + + assert obj["source"].is(JsonObject); + JsonObject source = obj["source"].as(JsonObject); + assert source["file"] == "PaymentService.x"; + assert source["line"] == 42; + } + + @Test + void shouldRenderGroupedDerivedLoggerAsNestedJson() { + JSONHandler handler = new JSONHandler(); + Record record = new Record( + time = new Time("2019-05-22T120123.456Z"), + message = "charged", + level = Level.Info, + attrs = [Attr.group("payments", [Attr.of("amount", 1099)])], + ); + + JsonObject obj = parseObject(handler.render(record)); + + assert obj["payments"].is(JsonObject); + JsonObject payments = obj["payments"].as(JsonObject); + assert payments["amount"] == 1099; + } + + private JsonObject parseObject(String rendered) { + Doc doc = new Parser(rendered.toReader()).parseDoc(); + assert doc.is(JsonObject); + return doc.as(JsonObject); + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x index be8e80929d..b50cdccc3e 100644 --- a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x @@ -44,10 +44,14 @@ service ListHandler } @Override - Handler withAttrs(Attr[] attrs) = this; + Handler withAttrs(Attr[] attrs) { + return attrs.empty ? this : new slogging.BoundHandler(this, attrs); + } @Override - Handler withGroup(String name) = this; + Handler withGroup(String name) { + return name == "" ? this : new slogging.BoundHandler(this, name); + } void setLevel(Level level) { rootLevel = level; diff --git a/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x b/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x new file mode 100644 index 0000000000..c2dd47f960 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x @@ -0,0 +1,40 @@ +import slogging.Attr; +import slogging.Logger; +import slogging.LoggerContext; + +/** + * Tests the optional `SharedContext` helper for request-scoped sloggers. + */ +class LoggerContextTest { + + @Test + void shouldExposeLoggerInsideUsingScope() { + LoggerContext context = new LoggerContext(); + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler).with([Attr.of("requestId", "r_1")]); + + assert !context.current(); + + using (context.bind(logger)) { + assert Logger current := context.current(); + current.info("inside"); + } + + assert handler.records.size == 1; + assert handler.records[0].attrs[0].key == "requestId"; + assert handler.records[0].attrs[0].value == "r_1"; + assert !context.current(); + } + + @Test + void shouldReturnFallbackWhenNoLoggerIsBound() { + LoggerContext context = new LoggerContext(); + ListHandler handler = new ListHandler(); + Logger fallback = new Logger(handler); + + context.currentOr(fallback).info("fallback"); + + assert handler.records.size == 1; + assert handler.records[0].message == "fallback"; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/NopHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/NopHandlerTest.x new file mode 100644 index 0000000000..3e5a855c64 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/NopHandlerTest.x @@ -0,0 +1,19 @@ +import slogging.Logger; +import slogging.NopHandler; + +/** + * Tests the shipped discard handler. Mirrors `lib_logging.NoopLogSink`: disabled level + * checks prevent records from being constructed or emitted. + */ +class NopHandlerTest { + + @Test + void shouldReportAllLevelsDisabled() { + Logger logger = new Logger(new NopHandler()); + + assert !logger.debugEnabled; + assert !logger.infoEnabled; + assert !logger.warnEnabled; + assert !logger.errorEnabled; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x b/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x new file mode 100644 index 0000000000..425c24d9fc --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x @@ -0,0 +1,34 @@ +import slogging.Attr; +import slogging.Level; +import slogging.Logger; + +/** + * Tests explicit source-location capture via `Logger.logAt(...)`. + */ +class SourceLocationTest { + + @Test + void shouldPopulateSourceFieldsWhenUsingLogAt() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + + logger.logAt(Level.Warn, "slow payment", "PaymentService.x", 77, + [Attr.of("elapsedMs", 451)]); + + assert handler.records.size == 1; + assert handler.records[0].sourceFile == "PaymentService.x"; + assert handler.records[0].sourceLine == 77; + assert handler.records[0].attrs[0].key == "elapsedMs"; + } + + @Test + void shouldLeaveSourceFieldsEmptyForNormalLogCalls() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + + logger.info("normal"); + + assert handler.records[0].sourceFile == Null; + assert handler.records[0].sourceLine == -1; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x b/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x new file mode 100644 index 0000000000..da5085e802 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x @@ -0,0 +1,47 @@ +import slogging.Attr; +import slogging.Handler; +import slogging.Level; +import slogging.Record; + +/** + * Test-only handler that records derivation hooks. It makes the `Handler.withAttrs` / + * `Handler.withGroup` part of the slog contract observable without depending on a + * concrete renderer. + */ +service TrackingHandler + implements Handler { + + public/private Int withAttrsCalls = 0; + public/private Int withGroupCalls = 0; + public/private Attr[] lastAttrs = []; + public/private String[] groups = []; + private Record[] recordList = new Record[]; + + @RO Record[] records.get() { + return recordList.toArray(Constant); + } + + @Override + Boolean enabled(Level level) { + return True; + } + + @Override + void handle(Record record) { + recordList.add(record); + } + + @Override + Handler withAttrs(Attr[] attrs) { + ++withAttrsCalls; + lastAttrs = attrs.toArray(Constant); + return this; + } + + @Override + Handler withGroup(String name) { + ++withGroupCalls; + groups = (groups + [name]).toArray(Constant); + return this; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x index 350eceba5c..2664ff125b 100644 --- a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x @@ -1,6 +1,5 @@ import slogging.Attr; import slogging.Logger; -import slogging.TextHandler; /** * Tests `Logger.withGroup(name)` — slog's namespace-prefix derivation. A grouped logger @@ -23,20 +22,44 @@ class WithGroupTest { } @Test - void shouldNotMutateAttrsOnGroupedLogger() { + void shouldGroupOnlySubsequentAttrs() { ListHandler handler = new ListHandler(); Logger base = new Logger(handler).with([Attr.of("env", "prod")]); Logger grouped = base.withGroup("payments"); grouped.info("charged", [Attr.of("amount", 1099)]); - // The grouped logger forwards the same attrs the parent carried, plus the call's. - // The "namespace" effect is something the *handler* applies on render — the - // record's attrs are unchanged. + // Matches Go slog ordering: attrs bound before WithGroup stay outside the group; + // attrs supplied after WithGroup are nested under the group. assert handler.records.size == 1; Attr[] attrs = handler.records[0].attrs; assert attrs.size == 2; assert attrs[0].key == "env"; - assert attrs[1].key == "amount"; + assert attrs[1].key == "payments"; + assert attrs[1].value.is(Attr[]); + + Attr[] paymentAttrs = attrs[1].value.as(Attr[]); + assert paymentAttrs.size == 1; + assert paymentAttrs[0].key == "amount"; + assert paymentAttrs[0].value == 1099; + } + + @Test + void shouldPutAttrsBoundAfterGroupInsideGroup() { + ListHandler handler = new ListHandler(); + Logger base = new Logger(handler); + Logger grouped = base.withGroup("payments").with([Attr.of("currency", "SEK")]); + + grouped.info("charged", [Attr.of("amount", 1099)]); + + Attr[] attrs = handler.records[0].attrs; + assert attrs.size == 1; + assert attrs[0].key == "payments"; + assert attrs[0].value.is(Attr[]); + + Attr[] paymentAttrs = attrs[0].value.as(Attr[]); + assert paymentAttrs.size == 2; + assert paymentAttrs[0].key == "currency"; + assert paymentAttrs[1].key == "amount"; } } diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x index 29c59688cc..b8dbf217aa 100644 --- a/manualTests/src/main/x/TestLogger.x +++ b/manualTests/src/main/x/TestLogger.x @@ -17,6 +17,7 @@ */ module TestLogger { package log import logging.xtclang.org; + package slog import slogging.xtclang.org; import log.BasicLogger; import log.BasicMarker; import log.ConsoleLogSink; @@ -24,6 +25,8 @@ module TestLogger { import log.LogSink; import log.Marker; import log.MDC; + import slog.Attr as SAttr; + import slog.Logger as SLogger; @Inject Console console; @@ -39,6 +42,9 @@ module TestLogger { console.print("--- runMDC (per-fiber context) ---"); runMDC(); + + console.print("--- runInjectedSlog (slog-shaped logger via @Inject) ---"); + runInjectedSlog(); } /** @@ -77,8 +83,8 @@ module TestLogger { * Default-logger injection. The runtime registers exactly one supplier under the * resource name `"logger"` (see `NativeContainer.initResources`), so this is the only * spelling that resolves: the field name `logger` matches the registered resource - * name. Any other field name — `@Inject Logger payments;` — would fail with an - * unresolved-injection error, by design. + * name. Any other field name — `@Inject Logger payments;` — would not resolve, + * by design. */ void runInjected() { @Inject Logger logger; @@ -120,8 +126,10 @@ module TestLogger { mdc.put("user", "u_7"); console.print($" [diagnostic] mdc.copyOfContextMap = {mdc.copyOfContextMap}"); - // (A) Through the runtime-injected RTLogger (service wrapper). - logger.info("via @Inject Logger (RTLogger service)"); + // (A) Through the runtime-injected BasicLogger const. This is intentionally + // not a service wrapper; keeping the call on this fiber is what makes MDC + // visible to BasicLogger.emit(). + logger.info("via @Inject Logger (BasicLogger const)"); // (B) Through a directly-constructed BasicLogger (no service wrapper). Logger direct = new BasicLogger("MDC.direct", new ConsoleLogSink()); @@ -129,4 +137,17 @@ module TestLogger { mdc.clear(); } + + /** + * Runtime injection for the slog-shaped library. This proves that the native + * injector can resolve the same resource name (`logger`) by requested type: + * `logging.Logger` and `slogging.Logger` are both injectable. + */ + void runInjectedSlog() { + @Inject SLogger logger; + + SLogger payments = logger.with([SAttr.of("requestId", "r_slog")]) + .withGroup("payments"); + payments.info("charged", [SAttr.of("amount", 1099)]); + } } diff --git a/xdk/build.gradle.kts b/xdk/build.gradle.kts index c764487c80..1734d8e542 100644 --- a/xdk/build.gradle.kts +++ b/xdk/build.gradle.kts @@ -112,6 +112,7 @@ dependencies { xtcModule(libs.xdk.json) xtcModule(libs.xdk.jsondb) xtcModule(libs.xdk.logging) + xtcModule(libs.xdk.slogging) xtcModule(libs.xdk.net) xtcModule(libs.xdk.oodb) xtcModule(libs.xdk.sec) From 7adadf284a6afc2b21f971eec7c8b92c4d13a516 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Mon, 4 May 2026 23:25:49 +0200 Subject: [PATCH 10/28] Tighten logging documentation structure Collapse repetitive reading paths and cleanup checklist-style sections in the logging docs. --- doc/logging/README.md | 155 +++++------------- doc/logging/design/why-slf4j-and-injection.md | 4 +- .../future/runtime-implementation-plan.md | 32 ++-- doc/logging/lib-logging-vs-lib-slogging.md | 72 +++----- doc/logging/open-questions.md | 26 ++- doc/logging/usage/custom-handlers.md | 31 ++-- doc/logging/usage/custom-sinks.md | 58 +++---- doc/logging/usage/slog-parity.md | 31 ++-- lib_logging/README.md | 31 ++-- lib_slogging/README.md | 19 +-- 10 files changed, 160 insertions(+), 299 deletions(-) diff --git a/doc/logging/README.md b/doc/logging/README.md index c5a9796fbd..3e4dd3fb43 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -1,126 +1,47 @@ # Ecstasy logging — start here -This branch (`lagergren/logging`) adds **two parallel logging libraries** to the -XDK. They are an experiment, not a final shipping decision: we built both so -reviewers can compare them side-by-side and tell us which API shape Ecstasy -should adopt long-term. - -The "why" is simple: Ecstasy should not invent an unfamiliar logging style when -the rest of the industry has already converged on two recognizable ones. The -branch proves both shapes in real XDK code: - -- **API/implementation split.** User code depends on `Logger`; routing lives behind - `LogSink` or `Handler`. -- **Injection-first acquisition.** The host container controls logging, the same way - it already controls `Console`. -- **Small default, open extension point.** The base library gives a console/no-op/ - memory implementation; production behavior is just another sink or handler. -- **Structured data.** Both designs can carry machine-readable fields to JSON/cloud - sinks without regexing messages. - -- **[`lib_logging`](../../lib_logging/)** — modelled on **SLF4J 2.x + Logback**. - Familiar to anyone with JVM background. 54 unit tests, end-to-end demo. -- **[`lib_slogging`](../../lib_slogging/)** — modelled on **Go's `log/slog`** - (the modern cloud-native idiom). 34 unit tests, parallel design. +This branch (`lagergren/logging`) adds two parallel logging libraries to the XDK +so reviewers can compare the two familiar industry shapes in real Ecstasy code +before choosing one canonical API. + +Both libraries use the same XDK-facing architecture: application code receives an +injected `Logger`, the container owns the backend, and production behavior is +provided by replacing a `LogSink` or `Handler`. Both can carry structured fields +to JSON/cloud sinks without parsing message text. + +| Library | Prior art | Current proof | +|---|---|---| +| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 54 focused XTC test methods and an injected manual demo. | +| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 34 focused XTC test methods and matching injected/manual coverage. | If you only have time to read one document, read **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — it is the comparison, the reviewer-question list, and the recommendation in one place. -## What we want from you - -There are **eleven explicit reviewer questions** in -[`lib-logging-vs-lib-slogging.md` § 5](lib-logging-vs-lib-slogging.md#5-what-we-want-reviewer-feedback-on) -and **seven language-design questions** (Q-D1..Q-D7) in -[`open-questions.md`](open-questions.md). Both are calibrated for someone who -has skimmed the headline comparison; you do not need to read the entire doc -tree to weigh in. - -The highest-value review question is: **which single API should Ecstasy make -canonical?** Keeping both forever would recreate the logging-fragmentation problem -these APIs were designed to avoid. - -## Reading paths - -Pick the one that matches what you are. - -### Reviewing the proposal — what should I read? - -1. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — the - side-by-side comparison, deep dive on markers, eleven reviewer questions, - tentative recommendation. -2. **[`api-cross-reference.md`](api-cross-reference.md)** — maps every POC type to the - official SLF4J or Go `log/slog` API it mirrors, with the Ecstasy-specific differences. -3. **[`cloud-integration.md`](cloud-integration.md)** — why the API choice is - the entry point to GCP / AWS / Azure observability ecosystems. Explains the - modern deployment world for readers whose intuition is "ship a CLI - installer." -4. **[`open-questions.md`](open-questions.md)** — questions for the XTC - language designers (Q-D1..Q-D7), tracking list of work not yet implemented, - implementation tiers. - -That is enough to weigh in on the design choice. The rest of the tree is -support material. - -### I'm an SLF4J / Logback Java engineer — show me what changes - -1. **[`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md)** — every - SLF4J idiom with the equivalent Ecstasy code beside it. -2. **[`api-cross-reference.md`](api-cross-reference.md)** — official SLF4J links beside the - local Ecstasy source files and difference notes. -3. **[`usage/slf4j-parity.md`](usage/slf4j-parity.md)** — exhaustive type-by-type and - method-by-method mapping. Reference, not narrative. -4. **[`usage/injected-logger-example.md`](usage/injected-logger-example.md)** — a complete - end-to-end example of `@Inject Logger logger;` with a runnable companion at - `manualTests/src/main/x/TestLogger.x`. - -### I'm a Go / slog engineer — show me what changes - -1. **[`api-cross-reference.md`](api-cross-reference.md)** — official Go `log/slog` links beside - the local Ecstasy source files and difference notes. -2. **[`usage/slog-parity.md`](usage/slog-parity.md)** — method-by-method mapping, - including `LoggerContext`, `logAt`, and handler derivation. -3. **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — has - side-by-side slog → Ecstasy code throughout. -4. **The `lib_slogging` source** at - [`lib_slogging/src/main/x/slogging/`](../../lib_slogging/src/main/x/slogging/) - — the API surface is small (Logger, Handler, Record, Attr, Level) and each - file has a doc-comment naming its slog counterpart. - -### I'm an XTC language designer — what's open? - -1. **[`open-questions.md`](open-questions.md)** §§ "Questions for the XTC - language / runtime designers" (Q-D1..Q-D7). Each question is tied to a - concrete piece of code we wrote and includes the workaround we adopted. -2. **[`design/design.md`](design/design.md)** — `lib_logging` architecture, including the - `const` vs `service` rule for sinks and the per-fiber `MDC` mechanism. - -### I want to write my own sink / handler - -1. **[`usage/custom-sinks.md`](usage/custom-sinks.md)** — guide to writing a custom - `LogSink`, with worked examples (counting, file, hierarchical, tee, - marker-filtering, JSON-line). Includes the `const` vs `service` decision. -2. **[`usage/custom-handlers.md`](usage/custom-handlers.md)** — guide to writing a custom - slog `Handler`, including derivation hooks and attr-based filtering. -3. **[`usage/structured-logging.md`](usage/structured-logging.md)** — how SLF4J 2.x - structured logging maps onto `lib_logging`, with a sketch of a JSON sink. - -### I want to deploy this to GCP / AWS / Azure - -1. **[`cloud-integration.md`](cloud-integration.md)** — adapter graphs for the - three major clouds, with concrete examples and the table-stakes API - features each cloud product depends on. -2. **[`future/logback-integration.md`](future/logback-integration.md)** — sketch of a future - configuration-driven binding (Tier 3 work, not in this PR). -3. **[`future/native-bridge.md`](future/native-bridge.md)** — could we instead plug *real - Java Logback* in via the JIT bridge? Investigation, evidence, recommendation. - -### I want to migrate existing Ecstasy code that already does ad-hoc logging - -1. **[`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md)** - — surveys `~/src/platform` and `~/src/examples` and shows how their - `console.print($"... Info :")` patterns would migrate. +## Review Focus + +The main design question is which single API Ecstasy should make canonical. +Keeping both indefinitely would recreate the logging fragmentation these APIs were +designed to avoid. The concrete reviewer prompts live in +[`lib-logging-vs-lib-slogging.md` § 5](lib-logging-vs-lib-slogging.md#5-what-we-want-reviewer-feedback-on); +language/runtime questions are tracked as Q-D1..Q-D7 in +[`open-questions.md`](open-questions.md). + +## Reading Paths + +Start with [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) if +you only read one file. The rest of the tree is organized by reader: + +| Reader | Start with | Then read | +|---|---|---| +| Proposal reviewer | [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | [`api-cross-reference.md`](api-cross-reference.md), [`cloud-integration.md`](cloud-integration.md), [`open-questions.md`](open-questions.md) | +| SLF4J / Logback engineer | [`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md) | [`usage/slf4j-parity.md`](usage/slf4j-parity.md), [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | +| Go `log/slog` engineer | [`usage/slog-parity.md`](usage/slog-parity.md) | [`api-cross-reference.md`](api-cross-reference.md), [`lib_slogging` source](../../lib_slogging/src/main/x/slogging/) | +| XTC language designer | [`open-questions.md`](open-questions.md) | [`design/design.md`](design/design.md), [`design/xdk-alignment.md`](design/xdk-alignment.md) | +| Sink / handler author | [`usage/custom-sinks.md`](usage/custom-sinks.md), [`usage/custom-handlers.md`](usage/custom-handlers.md) | [`usage/structured-logging.md`](usage/structured-logging.md) | +| Cloud/backend reviewer | [`cloud-integration.md`](cloud-integration.md) | [`future/logback-integration.md`](future/logback-integration.md), [`future/native-bridge.md`](future/native-bridge.md) | +| Migration reviewer | [`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md) | [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | ## Full doc index @@ -179,7 +100,7 @@ lib_logging/ SLF4J-shaped library │ ├── ConsoleLogSink.x default sink (const), forwards to @Inject Console │ ├── NoopLogSink.x drops every event (const) │ └── MemoryLogSink.x test-helper, captures events (service) - └── test/x/LoggingTest/ 54 unit tests + └── test/x/LoggingTest/ 54 focused XTC test methods lib_slogging/ slog-shaped sibling library ├── build.gradle.kts @@ -198,7 +119,7 @@ lib_slogging/ slog-shaped sibling library │ ├── JSONHandler.x JSON-Lines handler (const, lib_json renderer) │ ├── NopHandler.x drops every record (const) │ └── MemoryHandler.x test-helper (service) - └── test/x/SLoggingTest/ 34 unit tests + └── test/x/SLoggingTest/ 34 focused XTC test methods ``` ## Status diff --git a/doc/logging/design/why-slf4j-and-injection.md b/doc/logging/design/why-slf4j-and-injection.md index 08f86503a2..614a8f0eac 100644 --- a/doc/logging/design/why-slf4j-and-injection.md +++ b/doc/logging/design/why-slf4j-and-injection.md @@ -320,7 +320,9 @@ it because: library that no Java framework can quite ship, because Java doesn't have per-container injection at the language level. Ecstasy does. We should use it. -This is what "instantly familiar to all SLF4J users *and* better" looks like. +The result is still recognizable to SLF4J users, but acquisition and backend +selection follow Ecstasy's container model instead of Java's process-global binding +model. --- diff --git a/doc/logging/future/runtime-implementation-plan.md b/doc/logging/future/runtime-implementation-plan.md index 6f7f82a82a..f85ae4a1c2 100644 --- a/doc/logging/future/runtime-implementation-plan.md +++ b/doc/logging/future/runtime-implementation-plan.md @@ -1,8 +1,8 @@ -# Runtime implementation plan — getting to a demo-worthy round trip +# Runtime implementation plan — historical runtime wiring plan > **Status (2026-05): Stages 1–3 are landed.** The runtime now resolves -> `@Inject Logger logger;` end-to-end; the demo described in §"Definition of -> 'demo worthy'" works as written. **The plan diverged from this document in one +> `@Inject Logger logger;` end-to-end; the demo described in §"Demo target" +> works as written. **The plan diverged from this document in one > way worth flagging up front:** there is no separate `RTLogger.java` / > `xRTLogger`. `BasicLogger` is a `const`, and the runtime constructs it > directly in `javatools/.../NativeContainer.java` (`ensureLogger` / @@ -28,9 +28,9 @@ here either is on the critical path to that demo, or is needed to call the round The doc supersedes `../open-questions.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete fixes; the others remain genuinely open. -## Definition of "demo worthy" +## Demo target -The round trip is demo-worthy when **all** of these are true: +The runtime demo was considered complete when all of these were true: 1. A standalone Ecstasy program containing only ```ecstasy @@ -287,7 +287,7 @@ it, `@Inject Logger logger;` alone is already named after the enclosing module. ### Stage 5 — Validation: platform repo migration -The end-to-end test of "demo worthy" is migrating real code. +The end-to-end test is migrating real code. #### 5.1 — Pick three files in `~/src/platform` @@ -356,7 +356,7 @@ Stage 4 compiler-side default name ── ~half a day, separate PR Stage 5 platform migration ── ~1-2 days, follow-up PR ``` -Total to demo-worthy: **2-3 working days**, split across one runtime PR and one +Original estimate: **2-3 working days**, split across one runtime PR and one follow-up that polishes message formatting and MDC. The compiler change and the platform migration are independent and can land later. @@ -371,18 +371,14 @@ platform migration are independent and can land later. - Performance tuning (allocation, async). Premature; revisit when the platform repo has been on `lib_logging` for long enough that we have data. -## Owner-readable bullet summary +## Historical estimate -If you want this in three lines: - -- **Two days of Java work** in `javatools_jitbridge` makes `@Inject Logger` work. -- **Half a day of Ecstasy work** ports the SLF4J `{}` substitution and turns - `hello {}` into `hello world`. -- **Optional half day of compiler work** turns `@Inject Logger` into the - module-scoped logger SLF4J users expect, instead of always falling back to - `"default"`. - -After that, the platform-repo migration PR is the demo. +The original plan expected a small runtime slice for `@Inject Logger`, a smaller +Ecstasy slice for SLF4J `{}` substitution, and an optional compiler slice for +module-scoped default logger names. The first two concerns have since landed in a +different shape: `NativeContainer` constructs the injectable `BasicLogger` directly, +and `MessageFormatter` now handles `{}` substitution. Compiler-side default naming +remains separate from the manual demo and can land later. --- diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index cb05e6533c..1cabb4573c 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -1,16 +1,12 @@ # `lib_logging` vs `lib_slogging` — design comparison -Two parallel XDK logging libraries, both modelling the same end-to-end functionality -but each shaped after a different prior-art tradition: - -- **`lib_logging`** — modelled on SLF4J 2.x + Logback. Familiar to anyone with - Java/Kotlin/Scala experience. Mature ecosystem semantics: `Logger.info("hello {}", - arg)`, `MDC.put`, `MarkerFactory.getMarker`, fluent `.atInfo()` builder, sink/appender - split, severity-named levels. -- **`lib_slogging`** — modelled on Go 1.21's `log/slog`. The most modern "well-known" - structured logger. Attribute-first API: `logger.Info("hello", "key", value)` or - `logger.LogAttrs(...)`, derived loggers via `With(...)`, no MDC, no markers, - extensible integer levels, group nesting. +Two parallel XDK logging libraries model the same end-to-end capability but follow +different prior-art traditions. + +| Library | Prior art | Core shape | +|---|---|---| +| `lib_logging` | SLF4J 2.x + Logback | Named loggers, `{}` message formatting, MDC, markers, fluent event builders, and a `LogSink` backend boundary. | +| `lib_slogging` | Go 1.21 `log/slog` | Attribute-first events, derived loggers via `with(...)`, open integer levels, grouped attrs, and a `Handler` backend boundary. | Both libraries target the same set of features end-to-end so a reviewer can pick either, drop it into an Ecstasy program, and get the equivalent operational @@ -18,23 +14,16 @@ capability. The point of carrying two implementations is not to ship both foreve it's to let the XTC language designers and prospective users see the two designs side-by-side, in real Ecstasy, and tell us which one fits the language better. -This document: - -1. shows the same example written in both APIs; -2. compares the two designs across nine concrete axes; -3. analyses fit with Ecstasy idioms (`const` / `service` / `SharedContext` / fluent - builders); -4. lists the reviewer questions that motivate the experiment; -5. records a tentative recommendation, knowing it may shift after review. - -For direct links from each Ecstasy type to its official SLF4J or Go `log/slog` -counterpart, see [`api-cross-reference.md`](api-cross-reference.md). +The sections below show the same scenario in both APIs, compare the designs across +the axes that matter for XDK code, and end with the reviewer questions that should +decide which shape survives. Direct links from each Ecstasy type to its SLF4J or +Go `log/slog` counterpart are in [`api-cross-reference.md`](api-cross-reference.md). > Status note: both libraries are working comparison POCs. `lib_logging` has the -> fuller SLF4J surface (54 unit tests plus the injected manual demo); `lib_slogging` -> has the smaller slog surface (34 unit tests plus injected/manual coverage), with -> runtime injection, `lib_json` JSON rendering, source metadata, context binding, and -> handler derivation semantics implemented. +> fuller SLF4J surface (54 focused XTC test methods plus the injected manual demo); +> `lib_slogging` has the more compact slog surface (34 focused XTC test methods plus +> injected/manual coverage), with runtime injection, `lib_json` JSON rendering, source +> metadata, context binding, and handler derivation semantics implemented. --- @@ -257,12 +246,11 @@ veterans: yes; everyone else: no). #### My opinion: keep markers in `lib_logging`, drop them in `lib_slogging` -For an **SLF4J-shaped library**, removing markers would be a betrayal of the -concept "instantly familiar to anyone who has used SLF4J 2.x." Production +For an **SLF4J-shaped library**, markers are part of the contract. Production Logback configs filter on markers; existing code uses `MarkerFactory`; the fluent builder's `.addMarker(...)` is the standard idiom. Dropping it forces -JVM-veteran users to rewrite their mental model, which is the explicit thing -this library is trying not to do. **`lib_logging` keeps multi-marker support.** +JVM users to rewrite their mental model, which is exactly what the SLF4J-shaped +library is trying to avoid. **`lib_logging` keeps multi-marker support.** For a **slog-shaped library**, importing markers would be the wrong move. slog's single-concept attribute model is the half of its design that *is* worth @@ -751,22 +739,14 @@ Q-D6 in `open-questions.md`. ## 7. What lives in this PR -This branch (`lagergren/logging`) contains: - -- `lib_logging/` — the SLF4J-shaped library, near-feature-complete for the base API - (54 focused XTC test methods). Tracking list of remaining - items: `open-questions.md` § "SLF4J-style library work not yet implemented". -- `lib_slogging/` — the slog-shaped library, implemented to the same comparison - waterline with 34 focused XTC test methods. Runtime injection, `lib_json` JSON - rendering, explicit source - metadata, `LoggerContext`, handler derivation semantics, and a small handler - contract test kit are in scope for this branch. -- `api-cross-reference.md` — official SLF4J / Go slog API links mapped to local - Ecstasy source files and difference notes. -- This document — the design comparison and the explicit list of reviewer questions. -- `open-questions.md` — the unified question list (all "Q-D*" items added in this PR - call out language-design questions specifically). -- `design/design.md` — the SLF4J-side design, now with the resolved sink-type rule. +This branch (`lagergren/logging`) contains two comparable implementations and the +review material needed to choose between them. + +| Area | Contents | +|---|---| +| `lib_logging/` | SLF4J-shaped library, near-feature-complete for the base API, with 54 focused XTC test methods. | +| `lib_slogging/` | slog-shaped sibling library at the same comparison waterline, with 34 focused XTC test methods, runtime injection, `lib_json` JSON rendering, explicit source metadata, `LoggerContext`, handler derivation, and handler contract tests. | +| `doc/logging/` | Design comparison, API cross-reference, language/runtime questions, usage guides, and Tier 3 follow-up sketches. | We do not propose merging until reviewers have weighed in on at least Q-D6 (which API shape) and Q-D1 (sink interface convention). diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index aefb1c750e..33a79dd07a 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -232,9 +232,8 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | | W-11 | Doc cleanup | **Done for active docs.** Start-here docs, parity docs, examples, runtime-injection notes, and source comments now describe the current `BasicLogger`/`MDC`/multi-marker/formatter shape. Historical future-plan docs remain as historical context. | -The same tracking shape is mirrored for `lib_slogging` below so reviewers can see the -two libraries reach feature parity at the same waterline before we ask "which API do -you prefer?" +The `lib_slogging` section mirrors the same tracking shape so the API decision is +made between two implementations at the same proof level. ### Implementation tiers @@ -249,9 +248,8 @@ The W-items above are grouped into three tiers so we can decide how far to push **Decision (revised):** Tier 1 *and* Tier 2 land in this PR for both libraries. Reviewers asked for two **comparable** libraries — i.e. parity in implementation -maturity, not just in design intent — so they can give a feedback judgment without -discounting one side as "skeleton vs production." Tier 3 items remain out of scope -(separate PRs). +maturity, not just in design intent — so the API choice is not biased by one side +having more implemented surface. Tier 3 items remain out of scope (separate PRs). The `lib_slogging` parity translation: @@ -278,17 +276,15 @@ review have been closed as follows: | S-5 | Handler pre-resolution example | **Done.** `BoundHandler` implements the default `withAttrs` / `withGroup` semantics and shipped handlers use it; production backends can replace it with cached prefix implementations. | | S-6 | Handler test kit | **Done.** `SLoggingTest.HandlerContract` provides a small `testing/slogtest`-style helper for third-party handler conformance. | -## Decisions required to land in this order +## Decision ordering -1. **MDC scope (#3)** — must be made before Stage 1.3 of the plan. Without an answer - the `RTMDC.java` impl can't be written. -2. **Multiple markers per event (#4)** — should be made *before or with* Stage 1, so - `LogEvent` and `BasicEventBuilder` shapes are stable when the runtime starts - resolving them. -3. **Defensive copy (#11)** — decision is currently "document, no copy" for v0; revisit - before any async/default sink path captures caller-supplied mutable argument arrays. +| Decision | Why it comes first | +|---|---| +| MDC scope (#3) | Runtime wiring needs a settled per-fiber propagation model. | +| Multiple markers per event (#4) | `LogEvent` and `BasicEventBuilder` should be stable before more sinks depend on them. | +| Defensive copy (#11) | The current decision is "document, no copy"; revisit before async/default sinks capture caller-supplied mutable arrays. | -The remaining still-open items (#6, #7, #8, #12) can wait until there are real users. +The remaining open items (#6, #7, #8, #12) can wait for real usage data. --- diff --git a/doc/logging/usage/custom-handlers.md b/doc/logging/usage/custom-handlers.md index 46cfe078b7..bdb3741bef 100644 --- a/doc/logging/usage/custom-handlers.md +++ b/doc/logging/usage/custom-handlers.md @@ -35,10 +35,10 @@ pre-render attrs/group prefixes into a handler-specific derived instance so each Same rule as `LogSink`: -- **`const`** for stateless forwarders and fixed-configuration renderers: - `TextHandler`, `JSONHandler`, `NopHandler`. -- **`service`** for handlers with shared mutable state or resources: - `MemoryHandler`, a file writer, a network writer, an async queue. +| Shape | Use it for | Examples | +|---|---|---| +| `const` | Stateless forwarders and fixed-configuration renderers. | `TextHandler`, `JSONHandler`, `NopHandler`. | +| `service` | Shared mutable state or external resources. | `MemoryHandler`, file writers, network writers, async queues. | ## Counting records by level @@ -150,25 +150,22 @@ logger.info("user signed in", [ The SLF4J-shaped equivalent would use a marker. The slog-shaped equivalent keeps the category in the same `Attr` channel as every other structured field. -## Things handlers should not do +## Operational rules -- **Do not throw under normal failure modes.** If a remote endpoint is down, drop, - buffer, or fall back to console. -- **Do not do slow work in `enabled`.** It runs on every call before a record is built. -- **Do not parse the message to recover fields.** Fields belong in `Attr` values. -- **Do not add SLF4J-style placeholder formatting to the core handler path.** If a - migration adapter wants `"processed {}"` support, keep it outside `lib_slogging`. +| Rule | Reason | +|---|---| +| Keep `enabled` cheap. | It runs before record allocation on every logging call. | +| Keep normal backend failures inside the handler. | A failed remote endpoint should lead to drop, buffer, or fallback behavior, not an application failure. | +| Do not parse `record.message` to recover fields. | Structured data belongs in `Attr` values. | +| Do not add SLF4J-style placeholder formatting to the core handler path. | Migration sugar can live in an adapter; the slog core stays message-plus-attrs. | ## What a production JSON handler still needs The shipped `JSONHandler` renders through `lib_json`, preserves nested groups, and represents exceptions structurally. A production cloud sink built on the same handler -contract should still add: - -- output destination configuration (`Console`, file, stream, socket, HTTP exporter); -- support attr replacement/redaction rules; -- timestamp and level formatting options; -- expose an async wrapper for network or disk output. +contract would add destination configuration (`Console`, file, stream, socket, HTTP +exporter), attr replacement/redaction, timestamp and level formatting options, and an +async wrapper for network or disk output. --- diff --git a/doc/logging/usage/custom-sinks.md b/doc/logging/usage/custom-sinks.md index 6ede0fdc33..2bffc513ff 100644 --- a/doc/logging/usage/custom-sinks.md +++ b/doc/logging/usage/custom-sinks.md @@ -13,15 +13,13 @@ interface LogSink { } ``` -That's it. Two methods. Everything else — message substitution, MDC capture, marker -filtering, level check fast paths — happens in the `BasicLogger` *above* the sink. Sinks -just decide: +That's it. Everything else — message substitution, MDC capture, marker filtering, +and disabled-level fast paths — happens in `BasicLogger` above the sink. -1. **`isEnabled`** — should this event even be considered? Called once per log statement - on the hot path. Must be cheap. -2. **`log`** — the event has been built; emit it. The `LogEvent.message` is already - substituted; `mdcSnapshot` is captured. Sinks that want to render structured data - read `event.markers`, `event.exception`, `event.mdcSnapshot`, and `event.keyValues`. +| Method | Responsibility | +|---|---| +| `isEnabled` | Hot-path threshold/category check. It runs once per log statement and must stay cheap. | +| `log` | Emit the already-built event. `LogEvent.message` is substituted, `mdcSnapshot` is captured, and structured data is available in `markers`, `exception`, `keyValues`, and `mdcSnapshot`. | ## `const` or `service`? @@ -263,36 +261,20 @@ service JsonLineLogSink } ``` -## Things sinks should not do - -- **Don't throw.** A sink that throws breaks the program. If your network endpoint is - down, drop the event or fall back to stderr. Exceptions in `log()` are not the - caller's problem. -- **Don't block on the hot path.** If the sink is doing something slow (network, - encryption, disk), wrap it in an `AsyncLogSink` queue (future) rather than serializing - every log call's caller behind it. -- **Don't snapshot MDC yourself.** The `BasicLogger` already does it before the event - reaches you. `event.mdcSnapshot` is the immutable map you should render. -- **Don't re-format the message.** It has already been through `MessageFormatter` once - by the time it reaches the sink. Render `event.message` verbatim. - -## Things the runtime is expected to do for you - -- **Level check fast path.** `BasicLogger.emit` calls `sink.isEnabled` *before* - formatting the message, so a disabled-level call costs you exactly one method call - with cheap arithmetic. -- **MDC snapshot.** Captured in `BasicLogger`, stored in `event.mdcSnapshot`. -- **Throwable promotion.** `MessageFormatter.format` enforces SLF4J's "trailing - Throwable becomes the cause" rule before the event reaches you. - -## Quick checklist for a production-quality sink - -- [ ] `isEnabled` is `O(1)` or close to it — no I/O. -- [ ] `log` does not throw under any internal-failure mode. -- [ ] The threshold is configurable at runtime. -- [ ] Output is line-oriented or otherwise framed so `tail -f` is meaningful. -- [ ] The exception is rendered fully — message, stack frames, causes. -- [ ] If asynchronous, a flush mechanism exists for clean shutdown. +## Operational rules + +| Rule | Reason | +|---|---| +| Keep `isEnabled` close to `O(1)`. | It runs before every enabled or disabled log event; no I/O belongs there. | +| Do not let normal backend failures escape from `log`. | Logging should not break the caller. Drop, buffer, or fall back to stderr when a remote endpoint or file is unavailable. | +| Do not block callers on slow output. | Network, encryption, and disk-heavy paths should sit behind an async wrapper once that Tier 3 sink exists. | +| Render `event.message` as-is. | `MessageFormatter` has already performed `{}` substitution and trailing-exception promotion. | +| Render `event.mdcSnapshot`; do not read `MDC` again. | `BasicLogger` captured the immutable per-fiber context before the event crossed the sink boundary. | +| Frame production output. | Line-oriented or otherwise framed output keeps `tail -f`, log shippers, and cloud collectors predictable. | + +The sink can also rely on three upstream guarantees: disabled events are filtered +before formatting, MDC is captured in the event, and SLF4J-style trailing exceptions +have already been promoted to `event.exception`. --- diff --git a/doc/logging/usage/slog-parity.md b/doc/logging/usage/slog-parity.md index b739be76ed..603d058405 100644 --- a/doc/logging/usage/slog-parity.md +++ b/doc/logging/usage/slog-parity.md @@ -35,16 +35,13 @@ resolves resources by `(name, requested type)`, so both `logging.Logger logger` | `logger.Enabled(ctx, level)` | `logger.enabled(level)` | | `AddSource` output | `logger.logAt(level, message, sourceFile, sourceLine, attrs)` | -Differences: +The intentional call-shape differences are small: -- Go accepts alternating `"key", value` pairs and converts them into attrs. Ecstasy - uses explicit `Attr.of("key", value)` values, which avoids the bad-key case entirely. -- Go carries `context.Context`; the Ecstasy API keeps the common logging calls - context-free and offers `LoggerContext` as an Ecstasy `SharedContext` helper - for framework/request propagation. -- Go's `Error` convention is an attr such as `slog.Any("err", err)`. The Ecstasy POC - also has a dedicated `cause` parameter because `Exception` is a first-class type and - handlers/tests often want direct access. +| Difference | Rationale | +|---|---| +| Ecstasy uses explicit `Attr.of("key", value)` values instead of alternating `"key", value` pairs. | This removes Go's bad-key case and keeps attrs typed before they reach the handler. | +| Common logging calls do not carry a context parameter. | Ecstasy can use `LoggerContext` (`SharedContext`) for framework/request propagation without adding a context argument to every log call. | +| `error(...)` accepts `cause=` as well as attrs. | `Exception` is first-class in Ecstasy, and handlers/tests often need direct access to the cause. | ## `Attr` @@ -138,16 +135,14 @@ The slog design keeps the human message stable and puts variable data in attrs. to attrs and remove the model's main simplification. If migration sugar is useful later, it should live in an adapter module, not in the core slog API. -## What remains intentionally smaller than Go `log/slog` +## Deferred or intentionally omitted -- Top-level process-global functions such as `slog.Info(...)` are not modelled; - Ecstasy should prefer injected resources. -- Output destinations/options are not yet modelled as a `HandlerOptions` object; - `TextHandler` and `JSONHandler` write to injected `Console`. -- Automatic compiler/runtime source-location capture is not implemented; `logAt(...)` - is the explicit API that future sugar can target. -- `BoundHandler` provides correct derivation semantics. A high-performance production - backend can still override `withAttrs` / `withGroup` to cache serialized prefixes. +| Area | Current branch behavior | +|---|---| +| Process-global functions | Not modelled. Ecstasy should prefer injected resources over `slog.Info(...)`-style globals. | +| Handler options and destinations | `TextHandler` and `JSONHandler` write to injected `Console`; a production backend can add stream/file/socket/HTTP options. | +| Automatic source capture | `logAt(...)` is explicit today and is the method future compiler/runtime sugar can target. | +| Handler prefix caching | `BoundHandler` provides correct derivation semantics. High-performance handlers can override `withAttrs` / `withGroup` to cache serialized prefixes. | --- diff --git a/lib_logging/README.md b/lib_logging/README.md index 34d6d46d62..609b20b2d4 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -9,9 +9,9 @@ Experimental SLF4J-shaped logging library for Ecstasy. ## What this is -A logging library for Ecstasy that is **instantly familiar** to anyone who has used SLF4J 2.x -in Java: named loggers, levels, parameterized messages with `{}`, exception attachment, -markers, MDC, the SLF4J 2.x fluent event builder. Acquired by injection: +A logging library for Ecstasy with the SLF4J 2.x shape: named loggers, levels, +parameterized messages with `{}`, exception attachment, markers, MDC, and the SLF4J +2.x fluent event builder. Acquired by injection: ```ecstasy @Inject Logger logger; @@ -26,18 +26,13 @@ without touching caller code. All design docs live at the repo root under [`doc/logging/`](../doc/logging): -- [`README.md`](../doc/logging/README.md) — start-here guide and reading paths -- [`api-cross-reference.md`](../doc/logging/api-cross-reference.md) — official SLF4J links mapped - to each Ecstasy type and the local differences -- [`lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) — - side-by-side comparison with the slog-shaped sibling library -- [`design.md`](../doc/logging/design/design.md) — architecture, module layout, API↔impl boundary -- [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) — every SLF4J 2.x type and method, mapped -- [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) — Java SLF4J - example, then the same thing in Ecstasy, for every API -- [`custom-sinks.md`](../doc/logging/usage/custom-sinks.md) — guide to writing your own sink -- [`logback-integration.md`](../doc/logging/future/logback-integration.md) — how a future - logback-style configuration-driven backend would fit -- [`native-bridge.md`](../doc/logging/future/native-bridge.md) — could we plug real Java logging - libraries in via native code? Investigation and recommendation -- [`open-questions.md`](../doc/logging/open-questions.md) — things still to decide +| Doc | Purpose | +|---|---| +| [`README.md`](../doc/logging/README.md) | Start-here guide and reading paths. | +| [`lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) | Side-by-side comparison with the slog-shaped sibling library. | +| [`api-cross-reference.md`](../doc/logging/api-cross-reference.md) | Official SLF4J links mapped to each Ecstasy type and the local differences. | +| [`design.md`](../doc/logging/design/design.md) | Architecture, module layout, and API/implementation boundary. | +| [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) | SLF4J 2.x type and method mapping. | +| [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) | Java SLF4J examples next to equivalent Ecstasy code. | +| [`custom-sinks.md`](../doc/logging/usage/custom-sinks.md) | Guide to writing a custom sink. | +| [`open-questions.md`](../doc/logging/open-questions.md) | Remaining design and runtime decisions. | diff --git a/lib_slogging/README.md b/lib_slogging/README.md index 8522049fc8..54237b3d8c 100644 --- a/lib_slogging/README.md +++ b/lib_slogging/README.md @@ -35,17 +35,14 @@ implicit propagation. Backends implement the small `Handler` interface. Start with the repo-level docs: -- [`doc/logging/README.md`](../doc/logging/README.md) — start-here guide and reading paths -- [`doc/logging/api-cross-reference.md`](../doc/logging/api-cross-reference.md) — official Go - `log/slog` links mapped to each Ecstasy type and the local differences -- [`doc/logging/lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) — - side-by-side design comparison and reviewer questions -- [`doc/logging/usage/slog-parity.md`](../doc/logging/usage/slog-parity.md) — Go `log/slog` - method/type mapping and intentional Ecstasy differences -- [`doc/logging/usage/custom-handlers.md`](../doc/logging/usage/custom-handlers.md) — guide to - writing custom handlers -- [`doc/logging/cloud-integration.md`](../doc/logging/cloud-integration.md) — why both - shapes map cleanly to cloud logging systems +| Doc | Purpose | +|---|---| +| [`doc/logging/README.md`](../doc/logging/README.md) | Start-here guide and reading paths. | +| [`doc/logging/lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) | Side-by-side design comparison and reviewer questions. | +| [`doc/logging/api-cross-reference.md`](../doc/logging/api-cross-reference.md) | Official Go `log/slog` links mapped to each Ecstasy type and the local differences. | +| [`doc/logging/usage/slog-parity.md`](../doc/logging/usage/slog-parity.md) | Go `log/slog` method/type mapping and intentional Ecstasy differences. | +| [`doc/logging/usage/custom-handlers.md`](../doc/logging/usage/custom-handlers.md) | Guide to writing custom handlers. | +| [`doc/logging/cloud-integration.md`](../doc/logging/cloud-integration.md) | How the API shape maps to cloud logging systems. | The source files under [`src/main/x/slogging/`](src/main/x/slogging/) are intentionally small and each names its Go `log/slog` counterpart in the doc comment. From cbc29dbe4baa563108b0ef79c1953780e3b15e0c Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 09:34:26 +0200 Subject: [PATCH 11/28] Add canonical logging backend primitives --- doc/logging/README.md | 56 ++++--- doc/logging/api-cross-reference.md | 12 +- doc/logging/cloud-integration.md | 3 +- doc/logging/design/design.md | 54 +++---- doc/logging/design/why-slf4j-and-injection.md | 23 +-- doc/logging/future/logback-integration.md | 22 +-- doc/logging/future/native-bridge.md | 19 ++- .../future/runtime-implementation-plan.md | 14 +- doc/logging/lib-logging-vs-lib-slogging.md | 126 ++++++++-------- doc/logging/open-questions.md | 97 ++++++------- doc/logging/usage/custom-handlers.md | 31 +++- doc/logging/usage/custom-sinks.md | 75 ++++------ doc/logging/usage/ecstasy-vs-java-examples.md | 6 +- .../usage/platform-and-examples-adaptation.md | 8 +- doc/logging/usage/slog-parity.md | 12 +- doc/logging/usage/structured-logging.md | 65 +++------ .../java/org/xvm/runtime/NativeContainer.java | 54 ++++++- lib_logging/README.md | 5 +- lib_logging/build.gradle.kts | 1 + lib_logging/src/main/x/logging.x | 16 +- lib_logging/src/main/x/logging/AsyncLogSink.x | 94 ++++++++++++ lib_logging/src/main/x/logging/BasicLogger.x | 16 +- .../src/main/x/logging/CompositeLogSink.x | 29 ++++ .../src/main/x/logging/ConsoleLogSink.x | 9 +- .../src/main/x/logging/HierarchicalLogSink.x | 70 +++++++++ lib_logging/src/main/x/logging/JsonLogSink.x | 137 ++++++++++++++++++ .../src/main/x/logging/JsonLogSinkOptions.x | 112 ++++++++++++++ lib_logging/src/main/x/logging/LogEvent.x | 5 + lib_logging/src/main/x/logging/LogSink.x | 17 ++- lib_logging/src/main/x/logging/Logger.x | 15 ++ lib_logging/src/test/x/LoggingTest.x | 1 + .../src/test/x/LoggingTest/AsyncLogSinkTest.x | 35 +++++ .../test/x/LoggingTest/CompositeLogSinkTest.x | 39 +++++ .../x/LoggingTest/HierarchicalLogSinkTest.x | 38 +++++ .../src/test/x/LoggingTest/JsonLogSinkTest.x | 55 +++++++ .../test/x/LoggingTest/SourceLocationTest.x | 31 ++++ lib_slogging/README.md | 5 +- lib_slogging/src/main/x/slogging.x | 2 + .../src/main/x/slogging/AsyncHandler.x | 93 ++++++++++++ lib_slogging/src/main/x/slogging/Handler.x | 4 +- .../src/main/x/slogging/HandlerOptions.x | 73 ++++++++++ .../src/main/x/slogging/JSONHandler.x | 39 +++-- .../src/main/x/slogging/TextHandler.x | 35 +++-- .../test/x/SLoggingTest/AsyncHandlerTest.x | 34 +++++ .../src/test/x/SLoggingTest/JSONHandlerTest.x | 18 +++ manualTests/src/main/x/TestLogger.x | 8 +- 46 files changed, 1339 insertions(+), 374 deletions(-) create mode 100644 lib_logging/src/main/x/logging/AsyncLogSink.x create mode 100644 lib_logging/src/main/x/logging/CompositeLogSink.x create mode 100644 lib_logging/src/main/x/logging/HierarchicalLogSink.x create mode 100644 lib_logging/src/main/x/logging/JsonLogSink.x create mode 100644 lib_logging/src/main/x/logging/JsonLogSinkOptions.x create mode 100644 lib_logging/src/test/x/LoggingTest/AsyncLogSinkTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/CompositeLogSinkTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/HierarchicalLogSinkTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/JsonLogSinkTest.x create mode 100644 lib_logging/src/test/x/LoggingTest/SourceLocationTest.x create mode 100644 lib_slogging/src/main/x/slogging/AsyncHandler.x create mode 100644 lib_slogging/src/main/x/slogging/HandlerOptions.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/AsyncHandlerTest.x diff --git a/doc/logging/README.md b/doc/logging/README.md index 3e4dd3fb43..55e3c25244 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -1,8 +1,9 @@ # Ecstasy logging — start here This branch (`lagergren/logging`) adds two parallel logging libraries to the XDK -so reviewers can compare the two familiar industry shapes in real Ecstasy code -before choosing one canonical API. +so reviewers can compare two familiar industry shapes in real Ecstasy code. The +branch now recommends `lib_logging` as the canonical API shape and keeps +`lib_slogging` as the comparison/reference POC. Both libraries use the same XDK-facing architecture: application code receives an injected `Logger`, the container owns the backend, and production behavior is @@ -11,22 +12,22 @@ to JSON/cloud sinks without parsing message text. | Library | Prior art | Current proof | |---|---|---| -| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 54 focused XTC test methods and an injected manual demo. | -| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 34 focused XTC test methods and matching injected/manual coverage. | +| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 64 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | +| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 37 focused XTC test methods, injected/manual coverage, async handler, handler options, and JSON/redaction support. | If you only have time to read one document, read **[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — it is the comparison, the reviewer-question list, and the recommendation in one place. -## Review Focus +## Recommendation -The main design question is which single API Ecstasy should make canonical. -Keeping both indefinitely would recreate the logging fragmentation these APIs were -designed to avoid. The concrete reviewer prompts live in -[`lib-logging-vs-lib-slogging.md` § 5](lib-logging-vs-lib-slogging.md#5-what-we-want-reviewer-feedback-on); -language/runtime questions are tracked as Q-D1..Q-D7 in -[`open-questions.md`](open-questions.md). +Choose **`lib_logging`** as the canonical XDK logging facade. It gives Ecstasy the +SLF4J/Logback-shaped surface that JVM users recognize immediately: named loggers, +`{}` formatting, markers, MDC, fluent event builders, and a `LogSink` backend +boundary. The slog-shaped library remains useful review material and an adapter +candidate, but keeping both indefinitely would recreate the logging fragmentation +these APIs were designed to avoid. ## Reading Paths @@ -67,9 +68,9 @@ you only read one file. The rest of the tree is organized by reader: | [`usage/custom-sinks.md`](usage/custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | | [`usage/custom-handlers.md`](usage/custom-handlers.md) | How to write a custom slog `Handler`, with worked examples. | | [`usage/structured-logging.md`](usage/structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | -| **Tier 3 / future work** | | -| [`future/logback-integration.md`](future/logback-integration.md) | Sketch of a configuration-driven Logback-style binding. | -| [`future/native-bridge.md`](future/native-bridge.md) | Wrapping real Java Logback through the JIT bridge — feasibility analysis. | +| **Follow-up backend/compiler work** | | +| [`future/logback-integration.md`](future/logback-integration.md) | Configuration-driven Logback-style backend on top of the shipped primitives. | +| [`future/native-bridge.md`](future/native-bridge.md) | Optional Java Logback bridge — feasibility analysis and why it is not the default path. | | [`future/lazy-logging.md`](future/lazy-logging.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | | [`future/runtime-implementation-plan.md`](future/runtime-implementation-plan.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | @@ -98,9 +99,14 @@ lib_logging/ SLF4J-shaped library │ ├── BasicLogger.x canonical Logger impl (const) │ ├── BasicEventBuilder.x canonical builder impl │ ├── ConsoleLogSink.x default sink (const), forwards to @Inject Console + │ ├── JsonLogSink.x JSON-Lines sink (const), rendered by lib_json + │ ├── JsonLogSinkOptions.x root level, redaction, inclusion, field names + │ ├── CompositeLogSink.x multi-destination fanout (const) + │ ├── HierarchicalLogSink.x longest-prefix per-logger thresholds (service) + │ ├── AsyncLogSink.x bounded async wrapper (service) │ ├── NoopLogSink.x drops every event (const) │ └── MemoryLogSink.x test-helper, captures events (service) - └── test/x/LoggingTest/ 54 focused XTC test methods + └── test/x/LoggingTest/ 64 focused XTC test methods lib_slogging/ slog-shaped sibling library ├── build.gradle.kts @@ -117,20 +123,26 @@ lib_slogging/ slog-shaped sibling library │ ├── LoggerContext.x optional SharedContext helper │ ├── TextHandler.x default human-readable handler (const) │ ├── JSONHandler.x JSON-Lines handler (const, lib_json renderer) + │ ├── HandlerOptions.x threshold, redaction, source, field-name knobs + │ ├── AsyncHandler.x bounded async wrapper (service) │ ├── NopHandler.x drops every record (const) │ └── MemoryHandler.x test-helper (service) - └── test/x/SLoggingTest/ 34 focused XTC test methods + └── test/x/SLoggingTest/ 37 focused XTC test methods ``` ## Status **Both libraries are intended to compile and pass tests.** End-to-end demo runs in -`manualTests/TestLogger.x` and exercises both injected logger types (interpreter side; -JIT-side wiring is Tier 3). -Tier 1+2 work landed in this branch; Tier 3 (compiler default-name, -`AsyncLogSink`, `lib_logging_logback`, native bridge) is explicitly out of -scope for this PR — see `open-questions.md` for the tier breakdown. The -branch is ready for reviewer feedback on the design choice. +`manualTests/TestLogger.x` and exercises both injected logger types on the +interpreter side. Runtime fallback naming for injected `logging.Logger` now derives +from the caller namespace when the compiler only supplies the default field name +`"logger"`. Explicit source metadata is available through `Logger.logAt(...)`; +automatic compiler call-site capture remains the next compiler/runtime polish step. + +Tier 3 backend primitives have landed in the base libraries. A full +configuration-file loader, rolling-file/network destinations, and an optional Java +Logback bridge are deliberately not shipped in this branch; the docs explain where +those belong if the canonical `lib_logging` API is accepted. ## Reference diff --git a/doc/logging/api-cross-reference.md b/doc/logging/api-cross-reference.md index ebc2cde8dd..09bf0fdee6 100644 --- a/doc/logging/api-cross-reference.md +++ b/doc/logging/api-cross-reference.md @@ -30,9 +30,13 @@ Primary references: | [`MDC`](../../lib_logging/src/main/x/logging/MDC.x) | SLF4J [`MDC`](https://www.slf4j.org/api/org/slf4j/MDC.html) | Request-scoped string map captured on each event. | Acquired by `@Inject MDC mdc;` and backed by `SharedContext` for per-fiber semantics instead of Java static thread-local access. | | [`MessageFormatter`](../../lib_logging/src/main/x/logging/MessageFormatter.x) | SLF4J [`MessageFormatter`](https://www.slf4j.org/api/org/slf4j/helpers/MessageFormatter.html) | `{}` substitution, escape handling, and trailing-exception promotion. | Returns `(String, Exception?)`; an explicit `cause=` on the logger call wins over a promoted trailing exception. | | [`LoggingEventBuilder`](../../lib_logging/src/main/x/logging/LoggingEventBuilder.x), [`BasicEventBuilder`](../../lib_logging/src/main/x/logging/BasicEventBuilder.x) | SLF4J [`LoggingEventBuilder`](https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html) | Fluent `setMessage`, `addArgument`, `addMarker`, `setCause`, `addKeyValue`, `log`. | Supplier/lazy overloads are not implemented yet. The level check runs at `log(...)` so marker-aware sinks can participate. | -| [`LogEvent`](../../lib_logging/src/main/x/logging/LogEvent.x) | SLF4J [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html), Logback `ILoggingEvent` | Immutable event payload carrying logger name, level, message, markers, cause, arguments, MDC, and key/value pairs. | Ecstasy uses a `const`, with `Marker[] markers` plus `marker` convenience accessor and a `Map` for structured KV pairs. | +| [`LogEvent`](../../lib_logging/src/main/x/logging/LogEvent.x) | SLF4J [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html), Logback `ILoggingEvent` | Immutable event payload carrying logger name, level, message, markers, cause, arguments, MDC, source metadata, and key/value pairs. | Ecstasy uses a `const`, with `Marker[] markers` plus `marker` convenience accessor and a `Map` for structured KV pairs. `sourceFile` / `sourceLine` are explicit today and ready for compiler call-site capture later. | | [`LogSink`](../../lib_logging/src/main/x/logging/LogSink.x) | SLF4J provider boundary / Logback appender concept | Backend SPI: cheap enabled check plus event emission. | One interface allows both `const` forwarders and `service` stateful sinks; the `const`/`service` choice is documented in [`design/design.md`](design/design.md). | | [`ConsoleLogSink`](../../lib_logging/src/main/x/logging/ConsoleLogSink.x), [`NoopLogSink`](../../lib_logging/src/main/x/logging/NoopLogSink.x), [`MemoryLogSink`](../../lib_logging/src/main/x/logging/MemoryLogSink.x) | `slf4j-simple`, `slf4j-nop`, Logback `ListAppender`-style test capture | Minimal default output, silent output, and test capture. | Shipped as tiny Ecstasy sinks so the base library is useful without a Logback-style backend. | +| [`JsonLogSink`](../../lib_logging/src/main/x/logging/JsonLogSink.x), [`JsonLogSinkOptions`](../../lib_logging/src/main/x/logging/JsonLogSinkOptions.x) | Logback JSON encoders such as LogstashEncoder / cloud appenders | One JSON object per event, rendered with `lib_json`, preserving MDC, markers, key/value pairs, exception structure, and source metadata. | Options are programmatic rather than XML: root threshold, redacted keys, redaction token, field names, and inclusion flags. | +| [`CompositeLogSink`](../../lib_logging/src/main/x/logging/CompositeLogSink.x) | Multiple Logback appenders attached to one logger | Fans out an event to every delegate sink that enables it. | Composition is an ordinary `LogSink`, so callers and `BasicLogger` stay unchanged. | +| [`HierarchicalLogSink`](../../lib_logging/src/main/x/logging/HierarchicalLogSink.x) | Logback `LoggerContext` / per-logger level configuration | Longest-prefix logger-name threshold routing before delegating. | Programmatic API (`setLevel`, `clearLevel`, `effectiveLevel`) instead of `logback.xml`; config loaders can target this later. | +| [`AsyncLogSink`](../../lib_logging/src/main/x/logging/AsyncLogSink.x) | Logback `AsyncAppender` | Bounded queue wrapper around any sink, with `flush`, `close`, dropped-count, and pending-count probes. | Implemented as an Ecstasy `service`; events already captured MDC/source metadata before enqueue. | ## slog-shaped `lib_slogging` @@ -44,8 +48,10 @@ Primary references: | [`Record`](../../lib_slogging/src/main/x/slogging/Record.x) | Go [`slog.Record`](https://pkg.go.dev/log/slog#Record) | One immutable record carries time, level, message, source, and attrs. | Adds an explicit `Exception?` slot because Ecstasy exceptions are first-class and tests can assert on them directly. | | [`Attr`](../../lib_slogging/src/main/x/slogging/Attr.x) | Go [`slog.Attr`](https://pkg.go.dev/log/slog#Attr), [`slog.Group`](https://pkg.go.dev/log/slog#Group) | All structured data is key/value attrs; groups are nested attrs. | Uses `Object` values instead of Go's `slog.Value` kind union. Handlers inspect value types directly. | | [`Level`](../../lib_slogging/src/main/x/slogging/Level.x) | Go [`slog.Level`](https://pkg.go.dev/log/slog#Level) | Open integer severity line with canonical `Debug`, `Info`, `Warn`, `Error`. | Stores a display `label` alongside severity for simple rendering. | -| [`TextHandler`](../../lib_slogging/src/main/x/slogging/TextHandler.x) | Go [`TextHandler`](https://pkg.go.dev/log/slog#TextHandler) | Human-readable `key=value` output and group prefixing. | Writes through `@Inject Console`; escaping/options are intentionally minimal in this POC. | -| [`JSONHandler`](../../lib_slogging/src/main/x/slogging/JSONHandler.x) | Go [`JSONHandler`](https://pkg.go.dev/log/slog#JSONHandler) | One line-delimited JSON object per record, nested groups, structured exceptions. | Renders through XDK `lib_json` and writes through injected `Console`; handler options/output streams are future backend work. | +| [`HandlerOptions`](../../lib_slogging/src/main/x/slogging/HandlerOptions.x) | Go [`HandlerOptions`](https://pkg.go.dev/log/slog#HandlerOptions) | Root level, source inclusion, field names, and replacement policy live beside handlers. | This POC models the production knobs it needs now: threshold, redacted keys, redaction token, source inclusion, and JSON/text field names. | +| [`TextHandler`](../../lib_slogging/src/main/x/slogging/TextHandler.x) | Go [`TextHandler`](https://pkg.go.dev/log/slog#TextHandler) | Human-readable `key=value` output and group prefixing. | Writes through `@Inject Console` and honors `HandlerOptions` threshold, source inclusion, and redaction. | +| [`JSONHandler`](../../lib_slogging/src/main/x/slogging/JSONHandler.x) | Go [`JSONHandler`](https://pkg.go.dev/log/slog#JSONHandler) | One line-delimited JSON object per record, nested groups, structured exceptions. | Renders through XDK `lib_json`, writes through injected `Console`, and honors `HandlerOptions` field names, source inclusion, and redaction. | +| [`AsyncHandler`](../../lib_slogging/src/main/x/slogging/AsyncHandler.x) | Go async handler wrappers are usually third-party; same role as Logback `AsyncAppender` | Bounded queue wrapper around any handler, preserving already-constructed records. | Implemented as an Ecstasy `service` and keeps `withAttrs` / `withGroup` derivation by wrapping derived delegates. | | [`MemoryHandler`](../../lib_slogging/src/main/x/slogging/MemoryHandler.x), [`NopHandler`](../../lib_slogging/src/main/x/slogging/NopHandler.x) | Go test/discard handler patterns | Capture records for assertions or drop records entirely. | Small Ecstasy convenience handlers; not direct Go stdlib types. | ## No slog counterpart for `MessageFormatter` diff --git a/doc/logging/cloud-integration.md b/doc/logging/cloud-integration.md index 733e14d7a8..a8c0517837 100644 --- a/doc/logging/cloud-integration.md +++ b/doc/logging/cloud-integration.md @@ -213,8 +213,7 @@ the API doesn't expose them: different ways — see the comparison doc § 3.2.** 5. **An async / batched sink.** When a service emits 10k log events per second, blocking the request thread on disk I/O is unacceptable. Both - libraries' `AsyncLogSink` / `AsyncHandler` wrappers (W-5 in - `open-questions.md`) cover this; we have not implemented either yet. + libraries' `AsyncLogSink` / `AsyncHandler` wrappers cover this. Note that *all five* of these features exist for the cloud-deployment story, not for the local-tail-the-log-file story. They are exactly the features that diff --git a/doc/logging/design/design.md b/doc/logging/design/design.md index 3190d7bee4..c7f093b735 100644 --- a/doc/logging/design/design.md +++ b/doc/logging/design/design.md @@ -15,10 +15,15 @@ │ LogSink (interface) │ ← API ↔ impl boundary ├──────────────────────────────────┤ │ ConsoleLogSink │ ← shipped impls + │ JsonLogSink │ + │ CompositeLogSink │ + │ HierarchicalLogSink │ + │ AsyncLogSink │ │ NoopLogSink │ │ MemoryLogSink │ - │ (future) LogbackLogSink │ - │ (future) NativeSlf4jLogSink │ + │ (future) configured/destination │ + │ sinks │ + │ (optional future) native bridge │ └──────────────────────────────────┘ ``` @@ -150,8 +155,9 @@ Concretely, in this library: construction). Structurally identical to `lib_ecstasy/src/main/x/ecstasy/io/ConsoleAppender.x` and `ConsoleLog.x`, both `class`. - `MemoryLogSink`, `ListLogSink` — `service` (collect `events[]`). +- `AsyncLogSink` — `service` (owns a bounded queue and drain state). +- `HierarchicalLogSink` — `service` (owns mutable per-prefix level configuration). - A future `FileLogSink` owning a `Writer` — `service`. -- A future `AsyncLogSink` owning a worker queue — `service`. This matches the pattern used elsewhere in the XDK / platform ecosystem: @@ -246,37 +252,33 @@ helper lives in the host runtime's injector library, not here. ## Non-goals (v0) -Items deliberately *not* in scope for the base POC of `lib_logging`. Each is -either tracked as Tier 3 in `../open-questions.md` or punted on principle. +Items deliberately *not* in scope for the base POC of `lib_logging`. - **Distributed tracing context propagation.** `MDC` carries strings; that's it. A future tracing library can write its trace ID into MDC and let the logging library carry it, but `lib_logging` itself does not implement trace/span propagation. -- **Async / batched / buffered sinks.** The `LogSink` contract allows them; - the default sinks shipped here don't do them. The async wrapper sink lives - in the future `lib_logging_logback` (or its own follower module), not the - base library — `../open-questions.md` item 7. - **Configuration file format.** The default sink has one knob (`rootLevel`) - set at construction. A real config story (XML / programmatic / hot reload) - belongs to the future Logback-style backend; `../future/logback-integration.md` - sketches it. -- **Compile-time logger-name defaulting from the enclosing module.** - `@Inject Logger logger;` currently gets the fixed-name root logger; users - derive per-module loggers via `logger.named("...")`. Auto-substitution from - the enclosing module's qualified name is W-4 in `../open-questions.md` and - needs an XTC compiler change. + set at construction, and `JsonLogSinkOptions` covers JSON field names, + inclusion, threshold, and redaction. A real config story (XML / programmatic / + hot reload) belongs to the future Logback-style backend; + `../future/logback-integration.md` sketches it. +- **Automatic call-site source capture.** `Logger.logAt(...)` gives the runtime/compiler + a stable lowering target and sinks can render `sourceFile` / `sourceLine`, but the + compiler does not yet synthesize those arguments for ordinary `logger.info(...)` + calls. ## What isn't here yet -- **Compiler-side default name from module** — `@Inject Logger logger;` (no - `resourceName`) currently gets the fixed-name root logger; users derive per-class - loggers via `logger.named("...")`. The XTC-compiler change to substitute the - enclosing module's qualified name is `../future/runtime-implementation-plan.md` Stage 4 and - remains open. -- **`AsyncLogSink` / `lib_logging_logback` / native bridge** — see - `../open-questions.md` items 7 (async) and the `../future/logback-integration.md` / - `../future/native-bridge.md` follower-module sketches. +- **Compiler-side exact default names** — the interpreter runtime now falls back from + the field name `"logger"` to the caller namespace for canonical `logging.Logger` + injections. A compiler pass can still improve this by emitting the exact + module/class logger name as the resource name. +- **External Logback-style configuration loader / native bridge** — the base backend + primitives (`AsyncLogSink`, `CompositeLogSink`, `HierarchicalLogSink`, `JsonLogSink`) + are in this module. XML/JSON config loading, rolling files, network destinations, and + any Java Logback bridge remain explicit follow-up modules; see + `../future/logback-integration.md` and `../future/native-bridge.md`. - **Per-container override convenience** — open question 8. The runtime-side injection wiring lives in @@ -285,7 +287,7 @@ The runtime-side injection wiring lives in earlier interpose service `xRTLogger.java` was removed in favour of constructing `BasicLogger` directly so MDC fiber-locals survive injection (see Q-D5 in `../open-questions.md`). The real `MessageFormatter` is implemented (12 tests in -`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (54 +`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (64 passing as of this commit). diff --git a/doc/logging/design/why-slf4j-and-injection.md b/doc/logging/design/why-slf4j-and-injection.md index 614a8f0eac..05e1ae1e0b 100644 --- a/doc/logging/design/why-slf4j-and-injection.md +++ b/doc/logging/design/why-slf4j-and-injection.md @@ -113,9 +113,10 @@ shape gets us: They do not pick a backend, they do not have a config file, they cannot break downstream consumers by their choice. A library that does logging is a library that does logging *politely*. -- **An application author can swap `ConsoleLogSink` for `LogbackLogSink` (future) for - `NativeSlf4jLogSink` (future) without recompiling any library.** Behind a stable - injection point. +- **An application author can swap `ConsoleLogSink` for `JsonLogSink`, + `CompositeLogSink`, `HierarchicalLogSink`, a future configured backend, or an + optional native bridge without recompiling any library.** Behind a stable injection + point. - **Test authors get `MemoryLogSink` for free.** They wire it up once and assert on `events`. No global state, no `LogManager.getLogManager().reset()`, no flaky tests. - **Future structured-logging consumers (log aggregators, dashboards) get a stable event @@ -227,17 +228,19 @@ This is what every facade has aspired to, and almost-but-not-quite achieved. ### 3.2 The host application's experience -Wants production-grade logging? Provide a `LogbackLogSink` (future module). Provide it -*to the container* and every library inside the container picks it up. No -"add slf4j-logback to the classpath" ceremony, no version-skew warnings, no shading -games. The host owns the injector; the host owns the routing. +Wants production-grade logging? Provide a composed `LogSink` such as +`new AsyncLogSink(new CompositeLogSink([...]))`, with `JsonLogSink` or a future +destination-specific backend inside it. Provide it *to the container* and every +library inside the container picks it up. No "add slf4j-logback to the classpath" +ceremony, no version-skew warnings, no shading games. The host owns the injector; the +host owns the routing. Wants logs disabled for some embedded module? Override the injection for that module's sub-container. There is no Java equivalent that doesn't involve `MarkerFilter` hacks. -Wants a custom JSON wire format? Write a `JsonLineLogSink`, register it. Forty-plus -embedded libraries in the application start emitting JSON without one of them having -done anything. +Wants a custom JSON wire format? Configure `JsonLogSink` or write another `LogSink`, +register it. Forty-plus embedded libraries in the application start emitting JSON +without one of them having done anything. ### 3.3 The ops engineer's experience diff --git a/doc/logging/future/logback-integration.md b/doc/logging/future/logback-integration.md index fa13005b4a..b62d38325d 100644 --- a/doc/logging/future/logback-integration.md +++ b/doc/logging/future/logback-integration.md @@ -1,12 +1,13 @@ # Logback-style integration -`lib_logging` v0 ships a single, intentionally minimal default sink: `ConsoleLogSink`. -For real-world deployment we expect a Logback-equivalent backend — configuration-driven, -multi-appender, hierarchical — to live in a separate module. This document sketches how -that would be shaped. +`lib_logging` now ships the base Logback-style primitives directly: +`CompositeLogSink`, `HierarchicalLogSink`, `AsyncLogSink`, `JsonLogSink`, and +`JsonLogSinkOptions`. -It is intentionally *forward-looking*. Nothing here is implemented yet; the point is to -make sure today's API choices don't preclude tomorrow's backend. +This document is still forward-looking, but its scope is narrower now: it sketches the +optional configuration-driven backend layer that could sit on top of those primitives +and add config files, richer appenders, filters, hot reload, rolling files, and +destination-specific policies. ## What "Logback-style" means @@ -23,7 +24,9 @@ For SLF4J users, "Logback" means a specific bundle of features beyond the SLF4J | **Context-aware MDC rendering** | MDC values appear in formatted output without the message having to mention them. | | **Rolling file appenders** | Time-based, size-based, or composite rotation. | -The goal: a future `lib_logging_logback` module ships every one of those. +The base library covers the first three in programmatic form and includes async/JSON +building blocks. The goal of a future `lib_logging_logback` module is to provide the +configuration and destination ecosystem around them. ## The architectural fit @@ -251,8 +254,9 @@ service AsyncAppender(String name, Appender delegate, Int queueSize) } ``` -This is the Ecstasy-native equivalent of Logback's `AsyncAppender`. Fibers replace -threads; otherwise the design is identical. +This is the Ecstasy-native equivalent of Logback's `AsyncAppender`. The base library +already ships `AsyncLogSink`; a full backend can replace or compose it when it needs +drop/block policies, batching, or shutdown hooks. ## Migration story for SLF4J + Logback users diff --git a/doc/logging/future/native-bridge.md b/doc/logging/future/native-bridge.md index 6871be27ca..88077919d9 100644 --- a/doc/logging/future/native-bridge.md +++ b/doc/logging/future/native-bridge.md @@ -9,14 +9,13 @@ The short answer: - **Feasibility: high.** The bridge already does this for `jline`. Adding SLF4J + Logback is a small engineering task. -- **Recommendation: don't make it the primary path.** The pure-Ecstasy `LogSink` - design wins on portability, debuggability, and surface-area minimisation. A native - bridge is interesting as an *escape hatch* for users who want to use existing Java - Logback configuration assets unchanged. Document it; design for it; don't ship it as - the default. +- **Branch decision: don't ship it in this POC.** The pure-Ecstasy `LogSink` design is + the canonical path because it wins on portability, debuggability, and surface-area + minimisation. A native bridge remains an escape hatch for users who want to keep + existing Java Logback configuration assets unchanged. The rest of this doc walks through the evidence, the trade-offs, and what a native -bridge would look like if we built one. +bridge would look like if we built one later. ## Evidence the bridge can carry external Java dependencies @@ -183,8 +182,8 @@ Three options, ordered by recommendation: ### Option A (recommended): pure-Ecstasy `LogSink` is the primary path -Ship `ConsoleLogSink` as the default. Ship `lib_logging_logback` (the future module -described in `../future/logback-integration.md`) as a pure-Ecstasy implementation. +Ship `ConsoleLogSink` plus the base pure-Ecstasy backend primitives as the default. +Build any `lib_logging_logback` configuration module on top of those primitives. A native sink, if we ever ship one, is *one* of multiple `LogSink` choices, not the default. Users who want the Java Logback experience opt in explicitly. @@ -221,8 +220,8 @@ The native bridge angle is worth knowing about because: We've designed for that. But: the long-term answer is a pure-Ecstasy implementation. We get there incrementally: -ship the API, ship a small default sink, ship a logback-equivalent module written in -Ecstasy, optionally ship a native bridge for legacy interop. +ship the API, ship the base backend primitives, add destination/configuration modules +written in Ecstasy, and optionally ship a native bridge for legacy interop. The most important property is that none of these decisions break caller code. The `Logger` interface and the `LogSink` boundary make all of this swappable. diff --git a/doc/logging/future/runtime-implementation-plan.md b/doc/logging/future/runtime-implementation-plan.md index f85ae4a1c2..6903dd96e0 100644 --- a/doc/logging/future/runtime-implementation-plan.md +++ b/doc/logging/future/runtime-implementation-plan.md @@ -56,11 +56,10 @@ The runtime demo was considered complete when all of these were true: 4. `logger.atInfo().addMarker(AUDIT).addKeyValue("k", v).log("msg")` works through the fluent builder. -5. MDC `mdc.put("requestId", id)` shows up alongside any subsequent emission (rendered - by a sink that cares; default `ConsoleLogSink` would gain a small change to render - MDC entries). +5. MDC `mdc.put("requestId", id)` shows up alongside any subsequent emission; both + `ConsoleLogSink` and `JsonLogSink` render the captured MDC snapshot. -6. The focused `lib_logging` suite (54 XTC test methods) still compiles cleanly; +6. The focused `lib_logging` suite (64 XTC test methods) still compiles cleanly; `manualTests/src/main/x/TestLogger.x` invokes both `runInjected()` and the slog-shaped `runInjectedSlog()` path successfully. @@ -362,13 +361,14 @@ platform migration are independent and can land later. ## What this plan does *not* cover -- The future `lib_logging_logback` module (`../future/logback-integration.md`). That's a - separate, larger project for a configurable hierarchical backend. +- The future `lib_logging_logback` module (`../future/logback-integration.md`). The + base programmatic primitives now exist; a configurable backend remains a separate + larger project. - The native-Logback bridge approach (`../future/native-bridge.md`). Documented as feasible but not the primary path. - Slog-style alternative API (`ALTERNATIVE_DESIGN_SLOG_STYLE.md`). Documented as a thinkable alternative but not pursued. -- Performance tuning (allocation, async). Premature; revisit when the platform repo +- Performance tuning beyond the shipped async wrappers. Revisit when the platform repo has been on `lib_logging` for long enough that we have data. ## Historical estimate diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index 1cabb4573c..32e442689a 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -19,11 +19,13 @@ the axes that matter for XDK code, and end with the reviewer questions that shou decide which shape survives. Direct links from each Ecstasy type to its SLF4J or Go `log/slog` counterpart are in [`api-cross-reference.md`](api-cross-reference.md). -> Status note: both libraries are working comparison POCs. `lib_logging` has the -> fuller SLF4J surface (54 focused XTC test methods plus the injected manual demo); -> `lib_slogging` has the more compact slog surface (34 focused XTC test methods plus -> injected/manual coverage), with runtime injection, `lib_json` JSON rendering, source -> metadata, context binding, and handler derivation semantics implemented. +> Status note: both libraries are working comparison POCs. `lib_logging` is the +> recommended canonical facade and has the fuller SLF4J surface (64 focused XTC test +> methods plus the injected manual demo), including async/composite/hierarchical/JSON +> backend building blocks. `lib_slogging` has the more compact slog surface (37 focused +> XTC test methods plus injected/manual coverage), with runtime injection, async +> handler support, `lib_json` JSON rendering, redaction options, source metadata, +> context binding, and handler derivation semantics implemented. --- @@ -106,8 +108,8 @@ Both produce equivalent structured output. The shapes diverge in how | **Message** | Templated: `info("user {} did {}", [name, action])`. SLF4J-style `{}` placeholders. | Free-form string + attrs separately. No interpolation. | | **Sinks / handlers** | `LogSink.isEnabled(name, level, marker)`, `LogSink.log(event)`. Two methods. | `Handler.enabled(level)`, `Handler.handle(record)`, `Handler.withAttrs(attrs)`, `Handler.withGroup(name)`. Four methods — handler can pre-resolve attrs at derivation time. | | **Logger naming** | Hierarchical names: `logger.named("payments.stripe")`. The caller supplies the full category name, like `LoggerFactory.getLogger("...")`. | Loggers are uniform; categorisation lives in attrs (`"component" -> "payments.stripe"`). | -| **Source location** | Not captured by default. | `Logger.logAt(...)` explicitly populates `Record.sourceFile` / `sourceLine`; automatic call-site capture is future runtime/compiler work. | -| **Async / batching** | Wrapper sink (`AsyncLogSink`, future) drains a bounded queue. | Wrapper handler — same shape. | +| **Source location** | `Logger.logAt(...)` explicitly populates `LogEvent.sourceFile` / `sourceLine`; automatic call-site capture is future runtime/compiler work. | `Logger.logAt(...)` explicitly populates `Record.sourceFile` / `sourceLine`; automatic call-site capture is future runtime/compiler work. | +| **Async / batching** | `AsyncLogSink` wraps any sink with a bounded queue. | `AsyncHandler` wraps any handler with the same bounded-queue shape. | | **Familiarity bench** | Java/Kotlin/Scala. Anyone who has done structured logging on the JVM. | Go (1.21+). Increasingly the modern go-to in cloud-native code. | --- @@ -539,16 +541,21 @@ how SLF4J users intuitively expect it to work. slog forfeits this for uniformity ### 3.7 Source location -Out of scope for both libraries' v0, but worth noting: slog explicitly carries a -`pc`/`source` field on `Record` and the API contract says handlers may render it. -SLF4J leaves it to the sink. This isn't an architectural difference; it's a contract -difference. +Both libraries now expose an explicit source-aware call as the lowering target for +future compiler/runtime help: `logging.Logger.logAt(...)` populates +`LogEvent.sourceFile` / `sourceLine`; `slogging.Logger.logAt(...)` populates +`Record.sourceFile` / `sourceLine`. + +Automatic call-site capture remains compiler/runtime polish. The library-level +decision is made: source metadata belongs on the immutable event/record, not in a +backend-specific side channel. ### 3.8 Async / batching Same shape in both: a wrapper handler/sink owns a bounded queue and drains on a -worker fiber. Lives in a follower module (`lib_logging_logback` / -`lib_slogging_async`), not the base library. +worker fiber. The base libraries now ship these as `AsyncLogSink` and +`AsyncHandler` so slow output can be isolated without waiting for a full +configuration backend. ### 3.9 Familiarity @@ -687,53 +694,41 @@ lands. --- -## 6. Tentative recommendation - -We lean — softly, fully expecting reviewer pushback — toward **`lib_slogging` as the -canonical Ecstasy logging library, with one borrowed feature from `lib_logging`**: -the per-fiber `SharedContext` idea, recast as `LoggerContext` and *also* -optional / never the only path. The reasoning, ranked: - -1. **Conceptual economy.** One concept (`Attr`) covering markers, structured KVs, - message slots, and exception causes is meaningfully simpler than three. Less for - newcomers to learn, less for sink authors to handle. -2. **Modern fit.** slog is the design that emerged after a decade of structured- - logging wins in Java/Go. SLF4J is the design that those wins were retrofitted - onto. The 2026-era language probably wants the post-retrofit shape. -3. **Less compiler ask.** No hierarchical naming, no fluent builder, no marker DAG. - Smaller surface to support. -4. **MDC is genuinely useful.** Threading a request-aware logger through a - call graph is real work that real codebases get wrong. `LoggerContext` with - `Logger.with(...)` semantics, *opt in*, gives you the SLF4J win without the - "everything is implicit" cost. - -A third option — **hybrid SLF4J-facade with slog-shaped event model** — is -worth flagging once but not pushing for v0. Concretely: keep `Logger`, -`Marker`, `MDC`, the fluent builder; replace the free-form `Object[] arguments` -on `LogEvent` with a typed `Attr[]`; have `MessageFormatter` resolve `{}` -placeholders against `attrs` by index, but also have a `JsonLineLogSink` -render `attrs` directly as a JSON object. SLF4J users get their familiar -shape; cloud-side JSON pipelines get a clean wire format. The cost is -strictly more complexity than either pure design — two parallel mental -models in one library — so we don't recommend it for v0. The right time to -revisit is after one of the two pure designs is shipping and we know which -axis we wish we had more of. - -Counter-arguments worth taking seriously: - -- **Familiarity.** A great deal of Ecstasy's audience comes from JVM, where SLF4J is - reflexive. Forcing them to relearn a logging API for no reason other than design - preference is friction. -- **Markers and named loggers are not just "structured data dressed up funny" — they - enable per-category configuration that's awkward to express as attribute filters - in a config file.** -- **Fluent builders genuinely shine when the keys aren't statically known** — e.g. - building a log line from a request whose fields vary. Varargs handle this less - cleanly. - -The recommendation is therefore tentative. We expect to revisit it once the -reviewers — particularly the XTC language team — weigh in on questions Q-D1 through -Q-D6 in `open-questions.md`. +## 6. Recommendation + +Choose **`lib_logging` as the canonical Ecstasy logging library**. + +The decisive reason is not that the SLF4J shape is prettier. It is that the XDK +needs one logging facade that looks industrially boring to the largest likely +audience while still leaving room for structured output and backend innovation. +`lib_logging` now does that: + +- JVM users recognize the call surface immediately: `Logger`, named loggers, `{}` message + formatting, `Exception` cause handling, markers, MDC, and SLF4J 2.x fluent builders. +- Backend authors get one narrow extension point, `LogSink`, with working examples for + console, memory capture, JSON-Lines, async forwarding, multi-destination fanout, and + hierarchical per-logger level routing. +- Cloud and JSON users are not forced to parse message text. Structured key/value pairs + travel on `LogEvent.keyValues`; `JsonLogSink` renders them directly and applies + redaction policy before output. +- Runtime injection already works for the interpreter, and the native container now has + a default-name fallback for canonical `logging.Logger` injections. + +`lib_slogging` remains valuable. It is smaller, cleaner, and closer to modern +attribute-first logging. It also documents a real alternative for teams that prefer +Go's `slog` mental model. The right long-term shape, however, is not to ship two +competing XDK facades. Keep `lib_slogging` as review material and possible adapter +surface; make `lib_logging` the default API users learn first. + +A hybrid SLF4J-facade with slog-shaped event internals is worth revisiting after v0, +but it should not be the starting point. Mixing `Marker`, `MDC`, `Object[] arguments`, +and `Attr[]` in one public contract would make the facade harder to teach than either +pure design. + +The remaining compiler/runtime work is polish around the chosen facade, not a reason +to reopen the API choice: automatic source-location capture, better generated logger +names, and optional backend configuration loaders can all target the existing +`Logger` -> `LogSink` boundary. --- @@ -744,12 +739,13 @@ review material needed to choose between them. | Area | Contents | |---|---| -| `lib_logging/` | SLF4J-shaped library, near-feature-complete for the base API, with 54 focused XTC test methods. | -| `lib_slogging/` | slog-shaped sibling library at the same comparison waterline, with 34 focused XTC test methods, runtime injection, `lib_json` JSON rendering, explicit source metadata, `LoggerContext`, handler derivation, and handler contract tests. | -| `doc/logging/` | Design comparison, API cross-reference, language/runtime questions, usage guides, and Tier 3 follow-up sketches. | +| `lib_logging/` | Recommended canonical SLF4J-shaped library with 64 focused XTC test methods, runtime injection, source metadata API, JSON/redaction sink, async wrapper, composite fanout, and hierarchical per-logger thresholds. | +| `lib_slogging/` | slog-shaped sibling library with 37 focused XTC test methods, runtime injection, `lib_json` JSON rendering, handler options/redaction, async wrapper, explicit source metadata, `LoggerContext`, handler derivation, and handler contract tests. | +| `doc/logging/` | Design comparison, API cross-reference, language/runtime questions, usage guides, and backend follow-up sketches. | -We do not propose merging until reviewers have weighed in on at least Q-D6 (which -API shape) and Q-D1 (sink interface convention). +The branch is now opinionated: Q-D6 is answered as `lib_logging`. Review can still +challenge that decision, but the implementation and docs no longer leave the reader +with two equally recommended facades. --- diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index 33a79dd07a..65db27eaed 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -1,37 +1,30 @@ # Open questions for `lib_logging` -This is the running list of unresolved design and implementation questions, kept -deliberately short so it stays readable as the project moves. +This is the running list of design decisions and remaining implementation questions, +kept deliberately short so it stays readable as the project moves. -It is split into two sections: - -- **Addressed by the runtime plan** — questions that *had* been open, but - [`future/runtime-implementation-plan.md`](future/runtime-implementation-plan.md) now prescribes a - concrete fix. Those are summarized in one line each, with a link. -- **Still genuinely open** — questions whose answer the plan does not commit to. These - retain their full trade-off discussion, because someone will have to decide. - -When an "addressed by the plan" item lands in code, delete its row. +It is split into resolved design notes, implementation trackers, and the few items +that still need compiler/tooling/backend follow-up. Historical plan links are kept +where they explain why the branch made a decision. --- -## Addressed by the runtime implementation plan +## Runtime decisions | # | Question | Resolution | |---|---|---| | 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](future/runtime-implementation-plan.md#14--single-fixed-name-supplier-no-wildcard-injection). | -| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | The XTC compiler substitutes the enclosing module's qualified name. Optional for the demo. See [Stage 4](future/runtime-implementation-plan.md#stage-4--compiler-side-default-name-optional-but-high-impact). | -| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Recommend per-fiber if the runtime gives us fiber-locals; per-service-instance otherwise as a known-incorrect-for-concurrency v0 fallback. Decision required *before* `RTMDC.java` is written. See [Stage 3.1](future/runtime-implementation-plan.md#31--decide-the-scope). | +| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | Runtime fallback implemented for canonical `logging.Logger`: if the injected field name is `"logger"`, `NativeContainer` derives the caller namespace. Exact compiler-synthesized module/class names remain future polish. See [Stage 4](future/runtime-implementation-plan.md#stage-4--compiler-side-default-name-optional-but-high-impact). | +| 3 | **MDC scope: per-fiber, per-service, or per-call?** | Per-fiber semantics implemented in Ecstasy with `SharedContext`. The logger is injected as a `BasicLogger` const so calls stay on the caller fiber and can see the current MDC. | | 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](future/runtime-implementation-plan.md#21--real-messageformatterformat). | -| 9 | **Where does the runtime live?** | `javatools_jitbridge/src/main/java/org/xtclang/_native/logging/`, registered from `nMainInjector.addNativeResources()`. See [Stage 1.1–1.5](future/runtime-implementation-plan.md#stage-1--native-side-make-inject-logger-resolve). | -| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Yes — `RTConsoleLogSink.java` falls back to `System.err.println` if `Console` is not yet available. See [Stage 1.2](future/runtime-implementation-plan.md#12--rtconsolelogsinkjava). | +| 9 | **Where does the runtime live?** | Interpreter-side wiring lives in `javatools/src/main/java/org/xvm/runtime/NativeContainer.java`. The earlier native-resource class plan is superseded for this branch. | +| 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Not for this branch. The default `ConsoleLogSink` writes through `@Inject Console`; a bootstrap-native fallback can be added later only if runtime startup logging needs it. | --- -## Still genuinely open +## Design notes and remaining follow-up -These need a decision before the API is locked in. Numbered to preserve traceability -to earlier discussion. +Numbered to preserve traceability to earlier discussion. ### 4. Multiple markers per event — *resolved* @@ -64,18 +57,13 @@ signature was rejected because it would prohibit the legitimate stateless cases This rule is checked for parity against patterns in `platform/common`, `platform/host`, and `lib_xunit_engine` — see design/design.md for the citations. -### 7. Async / batched sinks - -Where does the async-wrapper sink live? - -**Options:** - -- **A. Ship `AsyncLogSink` in `lib_logging`.** Caller wraps a slow sink: `new - AsyncLogSink(new ConsoleLogSink())`. Worker fiber drains a bounded queue. -- **B. Defer to `lib_logging_logback`.** Keeps the base lib minimal. +### 7. Async / batched sinks — *resolved* -**Recommendation:** B. The configurable backend is the natural home for async; basics -ship in v0 unchanged. +**Resolution.** Adopted option A. `AsyncLogSink` ships in `lib_logging` as a small +bounded-queue wrapper around any `LogSink`; `AsyncHandler` mirrors the same shape for +`lib_slogging`. Slow output can now be isolated without waiting for a full +configuration backend. A future Logback-style module can still choose to compose or +replace the wrapper for richer batching policies. ### 8. Per-container override convenience — *resolved* @@ -176,16 +164,18 @@ Removing the wrapper and registering `BasicLogger` directly as the resource (see visibility to survive injection? If so, should `nMainInjector` grow a `registerConstResource(...)` helper to make this the documented happy path? -### Q-D6. SLF4J vs slog as the API shape — design preference +### Q-D6. SLF4J vs slog as the API shape — *resolved for this branch* We're maintaining two parallel libraries (`lib_logging` modeled after SLF4J 2.x and `lib_slogging` modeled after Go's `slog`) so the design tradeoffs can be evaluated side-by-side. See `lib-logging-vs-lib-slogging.md` for the comparison and the explicit list of things we want reviewer feedback on. -**Question:** which API style is a better long-term fit for Ecstasy idioms (services, -consts, `SharedContext`, fluent builders), and is there appetite for shipping both, -shipping one, or shipping a third synthesis? +**Resolution.** Recommend `lib_logging` as the canonical XDK facade. The deciding +factors are ecosystem familiarity, hierarchical logger routing, markers/MDC/fluent +builder parity for SLF4J users, and the now-working `LogSink` backend path. Keep +`lib_slogging` as comparison material and possible adapter surface, not as a second +permanent default facade. ### Q-D7. Cross-module default-argument resolution on `const` constructors @@ -213,7 +203,7 @@ form. --- -## SLF4J-style library (`lib_logging`) — work not yet implemented +## SLF4J-style library (`lib_logging`) — implementation tracker Tracking list so the SLF4J-shaped library stays comparable to `lib_slogging` in feature scope. Each item is a concrete deliverable with a one-line summary. @@ -223,12 +213,12 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | W-1 | Multiple markers per event | **Done.** `LogEvent.markers: Marker[]`, `marker` convenience accessor, `BasicEventBuilder.addMarker(...)` accumulation, and `ConsoleLogSink` multi-marker rendering are implemented and tested. | | W-2 | Logger interning in `Logger.named` | **Done.** Implemented `service LoggerRegistry(LogSink sink)` keyed by logger name; `BasicLogger.registry: LoggerRegistry?` (optional, defaulted `Null` so unattached loggers stay allocation-light); `LoggerFactory` lazily constructs a registry over its `defaultSink` and routes both `getLogger(...)` and `named(...)` through it. Tests: `NamedLoggerTest.shouldInternChildrenWhenRegistryAttached`, `NamedLoggerTest.shouldNotInternWhenRegistryAbsent`. | | W-3 | Real `MessageFormatter` | **Done.** Full SLF4J ParameterFormatter parity: `{}` substitution, `\{}` literal escape, `\\{}` double-escape, trailing-throwable promotion (with explicit-cause precedence enforced in `BasicLogger.emit`), defensive `safeToString`. 12 tests in `MessageFormatterTest`. The "stub" claim in earlier docs was stale. | -| W-4 | Compiler-side default name from module | `@Inject Logger logger;` (no resourceName) should fall back to the enclosing module's qualified name; see plan Stage 4. | -| W-5 | Async / batched sink (`AsyncLogSink`) | Bounded queue + worker fiber; lives in `lib_logging_logback` not in the base lib. Open question 7. | -| W-6 | Logback-shaped backend (`lib_logging_logback`) | Configuration-driven sink with per-logger thresholds, multiple appenders, layouts, filters. Sketched in `future/logback-integration.md`. | -| W-7 | Native bridge (`RTLogbackSink.java`) | Optional escape hatch wrapping real Logback via `javatools_jitbridge`. Sketched in `future/native-bridge.md`. | -| W-8 | Per-container override convenience | Helper for child containers wanting a different sink. Open question 8. | -| W-9 | Defensive copy of caller-supplied `Object[] arguments` | Open question 11. Decision: B (document, no copy) for v0. Listed here so it's not forgotten if v0 changes. | +| W-4 | Default injected logger naming | **Partially done.** `NativeContainer` now falls back from the field name `"logger"` to the caller namespace for canonical `logging.Logger` injections. Exact module/class names still need compiler lowering if we want deterministic names outside the runtime fallback. | +| W-5 | Async / batched sink (`AsyncLogSink`) | **Done.** Bounded queue + worker-fiber drain wrapper ships in the base library and is tested. | +| W-6 | Logback-shaped backend primitives | **Done at the base-programmatic level.** `CompositeLogSink`, `HierarchicalLogSink`, `JsonLogSink`, and `JsonLogSinkOptions` cover multi-appender fanout, per-logger threshold routing, JSON rendering, and redaction/field-name knobs. External config-file loading, rolling files, hot reload, network sinks, and filters remain follow-up backend modules. | +| W-7 | Native bridge (`RTLogbackSink.java`) | **Decision documented; not shipped.** The bridge remains an optional escape hatch in `future/native-bridge.md`. The canonical XDK path should stay pure Ecstasy first so users do not inherit Java Logback configuration/bootstrap behavior by default. | +| W-8 | Per-container override convenience | **Resolved: no library helper.** Child containers should override the injectable resource through the host injector API. | +| W-9 | Defensive copy of caller-supplied `Object[] arguments` | **Resolved: document, no copy.** Listed here so it can be revisited if async sinks become default-on. | | W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | | W-11 | Doc cleanup | **Done for active docs.** Start-here docs, parity docs, examples, runtime-injection notes, and source comments now describe the current `BasicLogger`/`MDC`/multi-marker/formatter shape. Historical future-plan docs remain as historical context. | @@ -237,19 +227,19 @@ made between two implementations at the same proof level. ### Implementation tiers -The W-items above are grouped into three tiers so we can decide how far to push -`lib_logging` before reviewer feedback determines which library survives. +The W-items above are grouped into three tiers to show what landed in this branch +and what remains deliberate follow-up work. | Tier | Scope | Items | Approx. cost | |---|---|---|---| | **1** | Close visible loose ends. No new design choices. | W-1 (multi-marker), W-11 (doc cleanup) | ~1 hour | | **2** | Make `lib_logging` fully comparable as a shippable library. | Tier 1 + W-2 (logger interning, needs a `LoggerRegistry` service since `BasicLogger` is `const`) + W-3 (real `MessageFormatter` with `{}` substitution + trailing-throwable promotion) | ~half a day | -| **3** | Out of scope for this PR — separate change(s). | W-4 (compiler default-name), W-5 (`AsyncLogSink`), W-6 (`lib_logging_logback`), W-7 (native bridge), W-8 (per-container override), W-9 (defensive copy), W-10 (linter) | per-item; multiple PRs | +| **3** | Backend/runtime polish. | W-4 runtime fallback naming, W-5 async wrapper, W-6 base Logback-style primitives, W-7 native-bridge decision, W-8 per-container override decision, W-9 defensive-copy decision, W-10 linter deferral | mostly landed; W-4 compiler lowering and W-10 linting remain separate work | -**Decision (revised):** Tier 1 *and* Tier 2 land in this PR for both libraries. -Reviewers asked for two **comparable** libraries — i.e. parity in implementation -maturity, not just in design intent — so the API choice is not biased by one side -having more implemented surface. Tier 3 items remain out of scope (separate PRs). +**Decision (revised):** Tier 1, Tier 2, and the backend-primitives part of Tier 3 +land in this PR for both libraries. The only remaining Tier 3 implementation work is +outside the base libraries: compiler-synthesized call-site metadata, linter support, +full config-file loading/destinations, and any optional Java bridge. The `lib_slogging` parity translation: @@ -258,8 +248,10 @@ The `lib_slogging` parity translation: | W-1 multi-marker on event | n/a — slog uses repeated attrs, already supported by `Attr[]` | | W-2 logger interning in `Logger.named` | n/a — slog has no hierarchical names; ergonomic `Logger.with(...)` already returns derived loggers | | W-3 real `MessageFormatter` with `{}` substitution | n/a — slog has no message templating; `TextHandler` / `JSONHandler` formatting completeness instead | +| W-5 async sink | `AsyncHandler` | +| W-6 backend options / JSON / redaction | `HandlerOptions`, `JSONHandler`, `TextHandler` | | W-11 doc cleanup | mirrored: keep `lib_slogging` doc references in sync | -| (test suite parity) | mirror `LoggingTest` → `SLoggingTest`: per-level routing, fluent-equivalent (`logger.with`), level thresholds, exception carriage, structured attrs, group nesting | +| (test suite parity) | mirror `LoggingTest` → `SLoggingTest`: per-level routing, fluent-equivalent (`logger.with`), level thresholds, exception carriage, structured attrs, group nesting, async wrapper, JSON/redaction | ## slog-style library (`lib_slogging`) — parity work addressed @@ -270,7 +262,7 @@ review have been closed as follows: | # | Item | Notes | |---|---|---| | S-1 | Runtime injection for `@Inject slogging.Logger logger;` | **Done.** `NativeContainer` now resolves resources by `(name, requested type)`, so `logging.Logger logger` and `slogging.Logger logger` can both use the default name. `manualTests/TestLogger.x` exercises both. | -| S-2 | Production JSON handler | **Done for the base POC.** `JSONHandler` renders through `lib_json`, escapes strings through `Printer`, preserves nested groups, emits source metadata, and renders exceptions structurally. Handler output destinations/options remain future backend work. | +| S-2 | Production JSON handler | **Done for the base POC.** `JSONHandler` renders through `lib_json`, escapes strings through `Printer`, preserves nested groups, emits source metadata, renders exceptions structurally, and honors `HandlerOptions` redaction/field-name/source knobs. Output destinations beyond `Console` remain a future handler family. | | S-3 | Source-location capture | **Done via explicit API.** `Logger.logAt(level, message, sourceFile, sourceLine, attrs, cause)` populates `Record.sourceFile` / `sourceLine`. Automatic compiler/runtime call-site capture remains a future enhancement. | | S-4 | Context story | **Done.** `LoggerContext` wraps `SharedContext` for framework/request propagation. Explicit logger passing remains the recommended default. | | S-5 | Handler pre-resolution example | **Done.** `BoundHandler` implements the default `withAttrs` / `withGroup` semantics and shipped handlers use it; production backends can replace it with cached prefix implementations. | @@ -282,9 +274,10 @@ review have been closed as follows: |---|---| | MDC scope (#3) | Runtime wiring needs a settled per-fiber propagation model. | | Multiple markers per event (#4) | `LogEvent` and `BasicEventBuilder` should be stable before more sinks depend on them. | -| Defensive copy (#11) | The current decision is "document, no copy"; revisit before async/default sinks capture caller-supplied mutable arrays. | +| Defensive copy (#11) | The current decision is "document, no copy"; revisit if async sinks become default-on. | -The remaining open items (#6, #7, #8, #12) can wait for real usage data. +The remaining genuinely open items are compiler/tooling work, destination-specific +production backends, and any optional Java bridge. --- diff --git a/doc/logging/usage/custom-handlers.md b/doc/logging/usage/custom-handlers.md index bdb3741bef..c3eaee9a4e 100644 --- a/doc/logging/usage/custom-handlers.md +++ b/doc/logging/usage/custom-handlers.md @@ -38,7 +38,7 @@ Same rule as `LogSink`: | Shape | Use it for | Examples | |---|---|---| | `const` | Stateless forwarders and fixed-configuration renderers. | `TextHandler`, `JSONHandler`, `NopHandler`. | -| `service` | Shared mutable state or external resources. | `MemoryHandler`, file writers, network writers, async queues. | +| `service` | Shared mutable state or external resources. | `MemoryHandler`, `AsyncHandler`, file writers, network writers. | ## Counting records by level @@ -103,6 +103,23 @@ Your own handler test can pass the handler instance plus a snapshot function tha returns the records it emitted. The contract checks that derived attrs are prepended and groups are nested before the final handler observes the record. +## Handler options and async wrapping + +The shipped text and JSON handlers accept `HandlerOptions`, which covers the +production knobs this POC needs without inventing a config-file format: + +```ecstasy +Handler handler = new JSONHandler(new HandlerOptions( + Level.Info, + ["authorization", "password"])); + +Logger logger = new Logger(new AsyncHandler(handler)); +``` + +`HandlerOptions` controls the root threshold, source inclusion, field names, and +key-based redaction. `AsyncHandler` owns the bounded queue and preserves `withAttrs` +/ `withGroup` semantics by wrapping the derived delegate handler. + ## Filtering audit events In the slog model, categories are attrs, not markers: @@ -159,13 +176,13 @@ category in the same `Attr` channel as every other structured field. | Do not parse `record.message` to recover fields. | Structured data belongs in `Attr` values. | | Do not add SLF4J-style placeholder formatting to the core handler path. | Migration sugar can live in an adapter; the slog core stays message-plus-attrs. | -## What a production JSON handler still needs +## What a production destination handler still needs -The shipped `JSONHandler` renders through `lib_json`, preserves nested groups, and -represents exceptions structurally. A production cloud sink built on the same handler -contract would add destination configuration (`Console`, file, stream, socket, HTTP -exporter), attr replacement/redaction, timestamp and level formatting options, and an -async wrapper for network or disk output. +The shipped `JSONHandler` renders through `lib_json`, preserves nested groups, honors +redaction options, includes source metadata when configured, and represents exceptions +structurally. A production cloud/file/network handler built on the same contract would +add destination configuration (`Console`, file, stream, socket, HTTP exporter), +retry/drop policy, batching, and backend-specific timestamp or severity formatting. --- diff --git a/doc/logging/usage/custom-sinks.md b/doc/logging/usage/custom-sinks.md index 2bffc513ff..4c47a956f5 100644 --- a/doc/logging/usage/custom-sinks.md +++ b/doc/logging/usage/custom-sinks.md @@ -33,8 +33,8 @@ that field to be `Passable`, so every implementation must be either `immutable` Cheap to construct, cheap to pass; methods run on the caller's fiber. - **`service`** — for sinks that genuinely have shared mutable state: an event buffer, a counter map, an open file handle, a worker queue. The service mailbox handles - concurrent ingress for free. Examples: `MemoryLogSink`, `ListLogSink` (test sinks), - the `FileLogSink`/`HierarchicalLogSink`/`TeeLogSink` examples below. + concurrent ingress for free. Examples: `MemoryLogSink`, `AsyncLogSink`, + `HierarchicalLogSink`, and the `FileLogSink` example below. If you find yourself reaching for `synchronized` blocks, you want a `service`. If your sink is "given a `Console`/`Writer`/`Function`, format and forward" — you want a @@ -136,6 +136,10 @@ Notes: Many sinks want different thresholds for different logger names — the classic `com.example.foo at DEBUG, root at INFO` pattern. +The shipped [`HierarchicalLogSink`](../../../lib_logging/src/main/x/logging/HierarchicalLogSink.x) +implements this longest-prefix policy. The sketch below shows the same mechanics in +one place for readers who want to write a custom variant. + ```ecstasy service HierarchicalLogSink implements LogSink { @@ -182,35 +186,20 @@ service HierarchicalLogSink This is roughly what Logback's `LoggerContext` does internally. Wrapping it as a sink keeps the rest of the library unaware. -## A composite (tee) sink - -```ecstasy -service TeeLogSink(LogSink[] sinks) - implements LogSink { +## A composite sink - @Override - Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { - for (LogSink sink : sinks) { - if (sink.isEnabled(loggerName, level, marker)) { - return True; - } - } - return False; - } +[`CompositeLogSink`](../../../lib_logging/src/main/x/logging/CompositeLogSink.x) is +the built-in equivalent of attaching multiple Logback appenders to one logger: - @Override - void log(LogEvent event) { - for (LogSink sink : sinks) { - if (sink.isEnabled(event.loggerName, event.level, event.marker)) { - sink.log(event); - } - } - } -} +```ecstasy +LogSink sink = new CompositeLogSink([ + new ConsoleLogSink(), + new FileLogSink(/var/log/app.log), +]); ``` -`TeeLogSink([new ConsoleLogSink(), new FileLogSink(/var/log/app.log)])` is the moral -equivalent of attaching multiple Logback appenders to one logger. +Each delegate still gets its own `isEnabled(...)` decision, so a JSON audit sink and a +human-readable console sink can sit behind the same logger without sharing policy. ## Filtering by marker @@ -242,32 +231,30 @@ Pair this with a `TeeLogSink` to e.g. send everything to the console but only ## Structured / JSON output -```ecstasy -service JsonLineLogSink - implements LogSink { - - @Inject Console console; - public/private Level rootLevel = Info; - - @Override - Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { - return level.severity >= rootLevel.severity; - } +The base library ships [`JsonLogSink`](../../../lib_logging/src/main/x/logging/JsonLogSink.x), +rendered through `lib_json`, with options for field names, redaction, inclusion of MDC +/ markers / key-values / source, and root threshold: - @Override - void log(LogEvent event) { - // See ../usage/structured-logging.md for the full sketch — emits one JSON object per line. - } -} +```ecstasy +LogSink sink = new JsonLogSink(new JsonLogSinkOptions( + Info, + ["authorization", "password"], + "***", + True, True, True, True, + "time", "level", "logger", "message", + "mdc", "markers", "exception", "source")); ``` +Caller code stays unchanged: `logger.atInfo().addKeyValue("password", "...").log(...)` +emits a structured value, and the sink decides whether to redact it. + ## Operational rules | Rule | Reason | |---|---| | Keep `isEnabled` close to `O(1)`. | It runs before every enabled or disabled log event; no I/O belongs there. | | Do not let normal backend failures escape from `log`. | Logging should not break the caller. Drop, buffer, or fall back to stderr when a remote endpoint or file is unavailable. | -| Do not block callers on slow output. | Network, encryption, and disk-heavy paths should sit behind an async wrapper once that Tier 3 sink exists. | +| Do not block callers on slow output. | Network, encryption, and disk-heavy paths should sit behind `AsyncLogSink` or a destination-specific async sink. | | Render `event.message` as-is. | `MessageFormatter` has already performed `{}` substitution and trailing-exception promotion. | | Render `event.mdcSnapshot`; do not read `MDC` again. | `BasicLogger` captured the immutable per-fiber context before the event crossed the sink boundary. | | Frame production output. | Line-oriented or otherwise framed output keeps `tail -f`, log shippers, and cloud collectors predictable. | diff --git a/doc/logging/usage/ecstasy-vs-java-examples.md b/doc/logging/usage/ecstasy-vs-java-examples.md index cb34e74cfc..18b5d7fe9f 100644 --- a/doc/logging/usage/ecstasy-vs-java-examples.md +++ b/doc/logging/usage/ecstasy-vs-java-examples.md @@ -213,9 +213,9 @@ log.LogSink sink = new log.ConsoleLogSink(log.Level.Debug); log.Logger logger = new log.BasicLogger("com.example", sink); ``` -For a richer per-logger configuration tree see `../future/logback-integration.md` — the future -`lib_logging_logback` module would expose a programmatic and/or file-based config API -analogous to Logback's `JoranConfigurator`. +For richer per-logger routing use `HierarchicalLogSink` programmatically today. A +future configured backend can add file-based config analogous to Logback's +`JoranConfigurator`; see `../future/logback-integration.md`. ## 9. Custom appender / sink diff --git a/doc/logging/usage/platform-and-examples-adaptation.md b/doc/logging/usage/platform-and-examples-adaptation.md index defbe1b602..98632c3370 100644 --- a/doc/logging/usage/platform-and-examples-adaptation.md +++ b/doc/logging/usage/platform-and-examples-adaptation.md @@ -234,10 +234,10 @@ is no string-prefix convention to maintain. `console.print($"... Error:")` exists. - **Net code change:** roughly -50 lines (no more timestamp prefix, no more level string, often no more `console` injection in files that only used it for logging). -- **Configuration model:** until `lib_logging_logback` ships, the default - `ConsoleLogSink` keeps existing operator dashboards working (it produces line- - oriented output). When it ships, the platform gets per-subsystem level control, - rolling files, JSON output for log shippers — none of which it has today. +- **Configuration model:** the base `ConsoleLogSink`, `JsonLogSink`, + `HierarchicalLogSink`, `CompositeLogSink`, and `AsyncLogSink` cover the common + programmatic cases now. A future configured backend adds file/network destinations, + rolling files, filters, and hot reload. - **Migration risk:** very low. Each call-site is independent; migrate file by file. - **Side benefit:** `MemoryLogSink` replaces every ad-hoc `errors.add(...)` / `errors.reportAll(...)` pattern with one library helper. diff --git a/doc/logging/usage/slog-parity.md b/doc/logging/usage/slog-parity.md index 603d058405..47bd82776a 100644 --- a/doc/logging/usage/slog-parity.md +++ b/doc/logging/usage/slog-parity.md @@ -14,8 +14,8 @@ Official reference: Go [`log/slog`](https://pkg.go.dev/log/slog). | `slog.New(handler)` | `new Logger(handler)` | | `logger.With("requestId", id)` | `logger.with([Attr.of("requestId", id)])` | | `logger.WithGroup("payments")` | `logger.withGroup("payments")` | -| `slog.NewTextHandler(w, opts)` | `new TextHandler(level)`; output currently goes through `@Inject Console`. | -| `slog.NewJSONHandler(w, opts)` | `new JSONHandler(level)`; renders through `lib_json` and writes through `@Inject Console`. | +| `slog.NewTextHandler(w, opts)` | `new TextHandler(level)` or `new TextHandler(new HandlerOptions(...))`; output goes through `@Inject Console`. | +| `slog.NewJSONHandler(w, opts)` | `new JSONHandler(level)` or `new JSONHandler(new HandlerOptions(...))`; renders through `lib_json` and writes through `@Inject Console`. | | framework/request context | `LoggerContext.bind(logger)` / `LoggerContext.currentOr(root)` | Runtime `@Inject slogging.Logger logger;` is wired in this branch. The native injector @@ -72,6 +72,12 @@ logger is derived, instead of doing that work on every record. The shipped handl `BoundHandler` for the default semantics, and tests include a small `HandlerContract` helper modelled on Go's `testing/slogtest`. +`AsyncHandler` can wrap any handler when output is slow: + +```ecstasy +Logger logger = new Logger(new AsyncHandler(new JSONHandler())); +``` + ## Context and source metadata The default Ecstasy style is explicit: @@ -140,7 +146,7 @@ later, it should live in an adapter module, not in the core slog API. | Area | Current branch behavior | |---|---| | Process-global functions | Not modelled. Ecstasy should prefer injected resources over `slog.Info(...)`-style globals. | -| Handler options and destinations | `TextHandler` and `JSONHandler` write to injected `Console`; a production backend can add stream/file/socket/HTTP options. | +| Output destinations | `TextHandler` and `JSONHandler` write to injected `Console`; a production backend can add stream/file/socket/HTTP handlers. | | Automatic source capture | `logAt(...)` is explicit today and is the method future compiler/runtime sugar can target. | | Handler prefix caching | `BoundHandler` provides correct derivation semantics. High-performance handlers can override `withAttrs` / `withGroup` to cache serialized prefixes. | diff --git a/doc/logging/usage/structured-logging.md b/doc/logging/usage/structured-logging.md index b35624b4e6..4eb10d1eba 100644 --- a/doc/logging/usage/structured-logging.md +++ b/doc/logging/usage/structured-logging.md @@ -126,59 +126,29 @@ just reads `event.keyValues`. The default `ConsoleLogSink` is intentionally simp appends KV pairs as `{key=value, key=value}` after the message, in the same spirit as Logback's simple text layouts. Structured sinks can render the same fields as real JSON. -A KV-aware structured sink — say `JsonLineLogSink` — would render every event as a -single JSON object per line: +The shipped `JsonLogSink` renders every event as a single JSON object per line: ```json {"timestamp":"2026-04-29T11:23:45.012Z","level":"INFO","logger":"com.example.PaymentService","message":"payment processed","paymentId":"p_123","amount":4200,"currency":"EUR","merchantId":"m_42","mdc":{"requestId":"r_abc"}} ``` -That's a separate file (`JsonLineLogSink.x`) implementing `LogSink`, and the runtime -chooses whether it's the active sink. Caller code is unchanged. +That's a separate `LogSink`, and the runtime chooses whether it's the active sink. +Caller code is unchanged. -### Sketch: a `JsonLineLogSink` +### Configuring `JsonLogSink` ```ecstasy -service JsonLineLogSink - implements LogSink { - - @Inject Console console; - public/private Level rootLevel = Info; - - @Override - Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { - return level.severity >= rootLevel.severity; - } - - @Override - void log(LogEvent event) { - StringBuffer json = new StringBuffer(); - json.append('{'); - appendField(json, "timestamp", event.timestamp.toString()); - appendField(json, "level", event.level.name); - appendField(json, "logger", event.loggerName); - appendField(json, "message", event.message); - if (!event.markers.empty) { - appendArray(json, "markers", event.markers.map(m -> m.name)); - } - for ((String k, Object v) : event.keyValues) { - appendField(json, k, v); - } - if (!event.mdcSnapshot.empty) { - appendObject(json, "mdc", event.mdcSnapshot); - } - if (Exception e ?= event.exception) { - appendField(json, "exception", e.toString()); - } - json.append('}'); - console.print(json.toString()); - } - - // ... helper methods elide JSON-escape detail; in production, defer to lib_json. -} +LogSink sink = new JsonLogSink(new JsonLogSinkOptions( + Info, + ["authorization", "password"], + "***", + True, True, True, True, + "time", "level", "logger", "message", + "mdc", "markers", "exception", "source")); ``` -Callers don't change. The sink is the only thing that knows the wire format is JSON. +`JsonLogSink` renders through `lib_json`, preserves MDC/markers/key-values/source, and +redacts configured keys before output. ## What about `MDC` vs key/value pairs? @@ -200,17 +170,16 @@ The smallest possible change to a structured-logging-heavy codebase moving from changes, and the LogSink choice on the deployment side. The bigger story — encoder/appender wiring, log aggregator integration — is handled by -the structured sink (`JsonLineLogSink` in the simple case, a future Logback-bridge sink -in the complex case). See `../future/logback-integration.md`. +the structured sink (`JsonLogSink` in the simple case, a future configured backend in +the complex case). See `../future/logback-integration.md`. ## What to build first vs. later For the next production-oriented cut, the right order is: -1. Add a `JsonLineLogSink` that renders one JSON object per event using `lib_json`. -2. Decide whether duplicate structured keys should remain "last value wins" (`Map`) or +1. Decide whether duplicate structured keys should remain "last value wins" (`Map`) or preserve duplicates (`KeyValuePair[]`, closer to SLF4J 2.x). -3. Add cloud-oriented layouts that map MDC, markers, and key/value pairs to the field +2. Add cloud-oriented layouts that map MDC, markers, and key/value pairs to the field names expected by GCP / AWS / Azure shippers. Everything beyond that — typed value formatting, schema validation, OpenTelemetry diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index c4dcd7fe20..1560aeb40e 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -401,8 +401,11 @@ private void initResources(ConstantPool pool) { pool.ensureClassConstant(modLogging, "Logger")); TypeConstant typeLogger = pool.ensureTerminalTypeConstant( pool.ensureClassConstant(modLogging, "BasicLogger")); + m_typeLoggingLogger = typeLoggerIf; + m_typeBasicLogger = typeLogger; addResourceSupplier(new InjectionKey("logger", typeLoggerIf), - (frame, hOpts) -> ensureLogger(frame, typeLogger, "logger")); + (frame, hOpts) -> ensureLogger(frame, typeLogger, + inferLoggerName(frame, "logger"))); TypeConstant typeMDC = pool.ensureTerminalTypeConstant( pool.ensureClassConstant(modLogging, "MDC")); @@ -452,6 +455,34 @@ private ObjectHandle ensureLogger(Frame frame, TypeConstant typeLogger, String n } } + /** + * Derive the default logger name for a canonical `logging.Logger` injection. When + * the compiler only supplies the field-name fallback (`logger`), use the enclosing + * method namespace as the logger name. If a future compiler supplies a resource name + * such as the module/class directly, the runtime keeps that explicit name. + */ + private String inferLoggerName(Frame frame, String sName) { + if ("logger".equals(sName) && frame.f_function != null) { + IdentityConstant idMethod = frame.f_function.getIdentityConstant(); + IdentityConstant idNamespace = idMethod.getNamespace(); + if (idNamespace != null) { + String sPath = idNamespace.getPathString(); + if (sPath != null && !sPath.isEmpty()) { + return sPath; + } + + ModuleConstant idModule = idMethod.getModuleConstant(); + if (idModule != null) { + String sModule = idModule.getName(); + if (sModule != null && !sModule.isEmpty()) { + return sModule; + } + } + } + } + return sName; + } + /** * Construct an instance of a parameter-less `const` from the lib_logging module via its * default constructor. Used by the [MDC] supplier; suitable for any other future @@ -792,12 +823,23 @@ public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, } } + if (isCanonicalLoggingLogger(type)) { + return ensureLogger(frame, m_typeBasicLogger, inferLoggerName(frame, sName)); + } + // for "Nullable" types the NativeContainer can only supply a trivial result; // anything better than that must be done naturally by a container that hosts the // calling container return type.isNullable() ? xNullable.NULL : null; } + private boolean isCanonicalLoggingLogger(TypeConstant type) { + TypeConstant typeLogger = m_typeLoggingLogger; + return typeLogger != null && + (typeLogger.equals(type) || typeLogger.isEquivalent(type) + || typeLogger.isEquivalent(type.removeNullable())); + } + @Override public Container getOriginContainer(SingletonConstant constSingle) { return this; @@ -982,13 +1024,17 @@ public String toString() { private ModuleStructure m_moduleNative; /** - * Map of IdentityConstants by name. + * Cached canonical logging types. Used to resolve module/class-named injections for + * `logging.Logger` after the fixed `"logger"` resource registration. */ - private final Map f_mapIdByName = new ConcurrentHashMap<>(); + private TypeConstant m_typeLoggingLogger; + private TypeConstant m_typeBasicLogger; /** - * Map of resource names for a name based lookup. + * Map of IdentityConstants by name. */ + private final Map f_mapIdByName = new ConcurrentHashMap<>(); + /** * Map of resources that are injectable from this container, keyed by their InjectionKey. */ diff --git a/lib_logging/README.md b/lib_logging/README.md index 609b20b2d4..42e4b2711c 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -4,8 +4,9 @@ Experimental SLF4J-shaped logging library for Ecstasy. > **Status:** Working POC. The core API, default sinks, MDC, markers, structured > key/value events, logger interning, runtime `@Inject Logger logger;` wiring, and -> unit tests are in this branch. JIT-side injection, async sinks, and the -> Logback-style backend remain future work; see [`doc/logging/`](../doc/logging). +> unit tests are in this branch. The base backend layer now includes async, +> composite, hierarchical-level, and JSON/redaction sinks. JIT-side injection and +> full configuration-file loading remain future work; see [`doc/logging/`](../doc/logging). ## What this is diff --git a/lib_logging/build.gradle.kts b/lib_logging/build.gradle.kts index 09b9e8bdee..5f2bcc475a 100644 --- a/lib_logging/build.gradle.kts +++ b/lib_logging/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { xdkJavaTools(libs.javatools) xtcModule(libs.xdk.ecstasy) + xtcModule(libs.xdk.json) xtcModuleTest(libs.javatools.bridge) xtcModuleTest(libs.xdk.xunit.engine) } diff --git a/lib_logging/src/main/x/logging.x b/lib_logging/src/main/x/logging.x index 7830dd7c20..935b4a543d 100644 --- a/lib_logging/src/main/x/logging.x +++ b/lib_logging/src/main/x/logging.x @@ -1,8 +1,11 @@ /** * Corresponds, as a whole module, to the `slf4j-api` jar in the Java ecosystem — plus a * minimum viable binding (analogous to `slf4j-simple`) so the library is usable out of - * the box. A future `lib_logging_logback` module would correspond to `logback-classic` - * (configuration-driven, multi-appender, hierarchical logger config). + * the box. The base module also includes the production building blocks that normally + * sit behind an SLF4J facade: async forwarding, multi-sink fanout, hierarchical logger + * thresholds, and JSON-Lines rendering with redaction knobs. A future + * `lib_logging_logback` module can add external configuration-file loading on top of + * those primitives. * * Ecstasy logging library. * @@ -38,13 +41,18 @@ * - [ConsoleLogSink] — default sink, writes through `@Inject Console` * - [NoopLogSink] — drops every event * - [MemoryLogSink] — captures events in memory; useful in tests + * - [JsonLogSink] — JSON-Lines sink rendered by `lib_json` + * - [CompositeLogSink] — Logback-style multi-appender fanout + * - [HierarchicalLogSink] — Logback-style longest-prefix level routing + * - [AsyncLogSink] — bounded async wrapper for slow sinks * - * A future `lib_logging_logback` module is expected to ship a configuration-driven sink with - * appenders, layouts, filters, and per-logger thresholds — see + * A future `lib_logging_logback` module is expected to ship configuration-file loading, + * richer appenders, layouts, filters, and hot reload — see * `doc/logging/future/logback-integration.md`. * * For official SLF4J API links mapped back to these Ecstasy types, see * `doc/logging/api-cross-reference.md`. */ module logging.xtclang.org { + package json import json.xtclang.org; } diff --git a/lib_logging/src/main/x/logging/AsyncLogSink.x b/lib_logging/src/main/x/logging/AsyncLogSink.x new file mode 100644 index 0000000000..1c5feea5e2 --- /dev/null +++ b/lib_logging/src/main/x/logging/AsyncLogSink.x @@ -0,0 +1,94 @@ +/** + * Logback-style async wrapper for any [LogSink]. + * + * The caller pays only the normal [isEnabled] check and the enqueue operation. A + * background fiber drains the queue and invokes the delegate sink. The [LogEvent] has + * already captured MDC and source metadata before it reaches this sink, so delayed + * emission preserves the caller's context. + */ +service AsyncLogSink(LogSink delegate, Int capacity) implements LogSink { + + /** + * Convenience: bounded queue with room for 1024 events. + */ + construct(LogSink delegate) { + construct AsyncLogSink(delegate, 1024); + } + + /** + * Events waiting for the background drain fiber. + */ + private LogEvent[] queue = new LogEvent[]; + + /** + * True while a drain fiber has been scheduled or is running. + */ + private Boolean draining = False; + + /** + * True after [close] has been called. + */ + private Boolean closed = False; + + /** + * Count of events dropped because the queue was full or the sink was closed. + */ + public/private Int droppedCount = 0; + + /** + * Current queue depth. Intended for tests and operational probes. + */ + @RO Int pending.get() = queue.size; + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return !closed && delegate.isEnabled(loggerName, level, marker); + } + + @Override + void log(LogEvent event) { + if (closed || queue.size >= capacity) { + ++droppedCount; + return; + } + + queue.add(event); + if (!draining) { + draining = True; + drain^(); + } + } + + /** + * Synchronously drain all currently queued events. + */ + void flush() { + drain(); + } + + /** + * Stop accepting new events. By default this drains pending events first. + */ + void close(Boolean flush = True) { + closed = True; + if (flush) { + drain(); + } else { + droppedCount += queue.size; + queue.clear(); + draining = False; + } + } + + /** + * Drain the queue on this service's fiber. + */ + private void drain() { + while (!queue.empty) { + LogEvent event = queue[0]; + queue.delete(0); + delegate.log(event); + } + draining = False; + } +} diff --git a/lib_logging/src/main/x/logging/BasicLogger.x b/lib_logging/src/main/x/logging/BasicLogger.x index 1de8d655d9..47af083d54 100644 --- a/lib_logging/src/main/x/logging/BasicLogger.x +++ b/lib_logging/src/main/x/logging/BasicLogger.x @@ -105,6 +105,17 @@ const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) emit(level, message, arguments, cause, marker); } + @Override + void logAt(Level level, String message, String sourceFile, Int sourceLine, + Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { + Marker[] markers = []; + if (Marker m ?= marker) { + markers = [m.freeze()]; + } + emitWith(level, message, arguments, cause, markers, [], + sourceFile=sourceFile, sourceLine=sourceLine); + } + @Override LoggingEventBuilder atTrace() = builderFor(Trace); @Override @@ -153,7 +164,8 @@ const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) * sink-side `isEnabled` fast path therefore sees frozen markers too. */ void emitWith(Level level, String message, Object[] arguments, Exception? cause, - Marker[] markers, Map keyValues) { + Marker[] markers, Map keyValues, + String? sourceFile = Null, Int sourceLine = -1) { // v0 policy on `arguments`: no defensive copy. Per `open-questions.md` Q11 the // caller is contractually required not to mutate the array between the return of // `info(...)` and any (possibly async) sink consuming the resulting `LogEvent`. @@ -180,6 +192,8 @@ const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) arguments = arguments, mdcSnapshot = mdc.copyOfContextMap, threadName = "", // TODO(runtime): expose current-fiber identity + sourceFile = sourceFile, + sourceLine = sourceLine, keyValues = keyValues, )); } diff --git a/lib_logging/src/main/x/logging/CompositeLogSink.x b/lib_logging/src/main/x/logging/CompositeLogSink.x new file mode 100644 index 0000000000..e3d8f05d5e --- /dev/null +++ b/lib_logging/src/main/x/logging/CompositeLogSink.x @@ -0,0 +1,29 @@ +/** + * Logback-style multi-appender sink. + * + * `CompositeLogSink` fans out each enabled event to every delegate sink that accepts + * the event's logger/level/primary-marker tuple. This is the Ecstasy equivalent of + * attaching multiple Logback appenders to one logger. + */ +const CompositeLogSink(LogSink[] sinks) + implements LogSink { + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + for (LogSink sink : sinks) { + if (sink.isEnabled(loggerName, level, marker)) { + return True; + } + } + return False; + } + + @Override + void log(LogEvent event) { + for (LogSink sink : sinks) { + if (sink.isEnabled(event.loggerName, event.level, event.marker)) { + sink.log(event); + } + } + } +} diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x index 9a6ce66408..9cd36306e2 100644 --- a/lib_logging/src/main/x/logging/ConsoleLogSink.x +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -23,13 +23,14 @@ * which are `class`/`const`-shaped wrappers over an injected `Console`. * * Sinks that *do* hold mutable shared state (e.g. `MemoryLogSink` collecting events, - * a future `FileLogSink` owning a `Writer`, a future `AsyncLogSink` owning a worker - * queue) must remain `service`. The rule of thumb is documented in + * `AsyncLogSink` owning a worker queue, `HierarchicalLogSink` owning mutable level + * configuration, or a future `FileLogSink` owning a `Writer`) must remain `service`. + * The rule of thumb is documented in * `doc/logging/design/design.md` ("Sink type: `const` vs `service`"). * * Configuration is intentionally minimal: a single `rootLevel` threshold applied to every - * logger. Per-logger / per-marker filtering is the job of richer sinks (see - * `doc/logging/future/logback-integration.md`). + * logger. Per-logger routing belongs in [HierarchicalLogSink]; multi-destination routing + * belongs in [CompositeLogSink]. */ const ConsoleLogSink(Level rootLevel) implements LogSink { diff --git a/lib_logging/src/main/x/logging/HierarchicalLogSink.x b/lib_logging/src/main/x/logging/HierarchicalLogSink.x new file mode 100644 index 0000000000..b7bb2a6b7c --- /dev/null +++ b/lib_logging/src/main/x/logging/HierarchicalLogSink.x @@ -0,0 +1,70 @@ +/** + * Logback-style per-logger threshold wrapper. + * + * The sink keeps a mutable map of logger-name prefixes to levels. The longest matching + * prefix wins; when no prefix matches, [rootLevel] applies. Events that pass the + * threshold are forwarded to [delegate]. + */ +service HierarchicalLogSink(LogSink delegate, Level rootLevel) + implements LogSink { + + /** + * Convenience: root threshold `Info`. + */ + construct(LogSink delegate) { + construct HierarchicalLogSink(delegate, Info); + } + + /** + * Mutable per-prefix configuration, e.g. `"com.example.payments" -> Debug`. + */ + private Map levels = new HashMap(); + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= effectiveLevel(loggerName).severity + && delegate.isEnabled(loggerName, level, marker); + } + + @Override + void log(LogEvent event) { + if (isEnabled(event.loggerName, event.level, event.marker)) { + delegate.log(event); + } + } + + /** + * Configure a logger-name prefix. The longest prefix wins. + */ + void setLevel(String loggerPrefix, Level level) { + levels.put(loggerPrefix, level); + } + + /** + * Remove a logger-name prefix override. + */ + void clearLevel(String loggerPrefix) { + levels.remove(loggerPrefix); + } + + /** + * Resolve the effective threshold for `loggerName`. + */ + Level effectiveLevel(String loggerName) { + String name = loggerName; + while (True) { + if (Level level := levels.get(name)) { + return level; + } + + if (Int dot := name.lastIndexOf('.')) { + if (dot <= 0) { + return rootLevel; + } + name = name[0 ..< dot]; + } else { + return rootLevel; + } + } + } +} diff --git a/lib_logging/src/main/x/logging/JsonLogSink.x b/lib_logging/src/main/x/logging/JsonLogSink.x new file mode 100644 index 0000000000..707fa35974 --- /dev/null +++ b/lib_logging/src/main/x/logging/JsonLogSink.x @@ -0,0 +1,137 @@ +import json.Doc; +import json.JsonArray; +import json.JsonObject; +import json.Printer; + +/** + * Production-oriented JSON-lines sink for the canonical SLF4J-shaped API. + * + * It renders through `lib_json`, preserves MDC, markers, structured key/value pairs, + * exceptions, and explicit source metadata, and supports key-based redaction through + * [JsonLogSinkOptions]. + */ +const JsonLogSink(JsonLogSinkOptions options) + implements LogSink { + + /** + * Convenience: Info threshold, all standard fields enabled, no redacted keys. + */ + construct() { + construct JsonLogSink(new JsonLogSinkOptions()); + } + + /** + * Convenience: configure only the root threshold. + */ + construct(Level rootLevel) { + construct JsonLogSink(new JsonLogSinkOptions(rootLevel)); + } + + @Inject Console console; + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return level.severity >= options.rootLevel.severity; + } + + @Override + void log(LogEvent event) { + console.print(render(event)); + } + + /** + * Render the event as one compact JSON object. + */ + String render(LogEvent event) { + return Printer.DEFAULT.render(toJson(event)); + } + + /** + * Convert the event into a JSON object. Exposed for tests and custom wrappers. + */ + JsonObject toJson(LogEvent event) { + JsonObject obj = json.newObject(); + obj.put(options.timeKey, event.timestamp.toString()); + obj.put(options.levelKey, event.level.name); + obj.put(options.loggerKey, event.loggerName); + obj.put(options.messageKey, event.message); + + if (options.includeMarkers && !event.markers.empty) { + JsonArray markers = json.newArray(); + for (Marker marker : event.markers) { + markers.add(marker.name); + } + obj.put(options.markersKey, markers.toArray(Constant)); + } + + if (options.includeMdc && !event.mdcSnapshot.empty) { + JsonObject mdc = json.newObject(); + for ((String key, String value) : event.mdcSnapshot) { + mdc.put(key, redacted(key, value)); + } + obj.put(options.mdcKey, mdc.makeImmutable()); + } + + if (options.includeKeyValues && !event.keyValues.empty) { + for ((String key, Object value) : event.keyValues) { + obj.put(key, redacted(key, value)); + } + } + + if (Exception e ?= event.exception) { + obj.put(options.exceptionKey, exceptionJson(e)); + } + + if (options.includeSource) { + if (String file ?= event.sourceFile) { + JsonObject source = json.newObject(); + source.put("file", file); + if (event.sourceLine >= 0) { + source.put("line", event.sourceLine.toIntLiteral()); + } + obj.put(options.sourceKey, source.makeImmutable()); + } + } + + return obj.makeImmutable(); + } + + private Doc redacted(String key, Object value) { + return options.redacts(key) ? options.redaction : toJsonValue(value); + } + + private Doc toJsonValue(Object value) { + if (String s := value.is(String)) { + return s; + } + if (Boolean b := value.is(Boolean)) { + return b; + } + if (IntLiteral n := value.is(IntLiteral)) { + return n; + } + if (FPLiteral n := value.is(FPLiteral)) { + return n; + } + if (IntNumber n := value.is(IntNumber)) { + return n.toIntLiteral(); + } + if (FPNumber n := value.is(FPNumber)) { + return n.toFPLiteral(); + } + if (Exception e := value.is(Exception)) { + return exceptionJson(e); + } + return value.toString(); + } + + private JsonObject exceptionJson(Exception e) { + JsonObject obj = json.newObject(); + obj.put("type", &e.class.name); + obj.put("message", e.message); + if (Exception cause ?= e.cause) { + obj.put("cause", exceptionJson(cause)); + } + return obj.makeImmutable(); + } +} diff --git a/lib_logging/src/main/x/logging/JsonLogSinkOptions.x b/lib_logging/src/main/x/logging/JsonLogSinkOptions.x new file mode 100644 index 0000000000..cda62c9ade --- /dev/null +++ b/lib_logging/src/main/x/logging/JsonLogSinkOptions.x @@ -0,0 +1,112 @@ +/** + * Configuration for [JsonLogSink]. + * + * This is intentionally small but production-oriented: it covers thresholding, + * field naming, MDC/marker/source inclusion, and key-based redaction without + * committing the XDK to a full Logback XML/JSON configuration language. + */ +const JsonLogSinkOptions( + /** + * Lowest event level accepted by the sink. + */ + Level rootLevel, + /** + * MDC or key/value keys whose values should be replaced with [redaction]. + */ + String[] redactedKeys, + /** + * Replacement value emitted for redacted keys. + */ + String redaction, + /** + * True to include [LogEvent.mdcSnapshot]. + */ + Boolean includeMdc, + /** + * True to include [LogEvent.markers]. + */ + Boolean includeMarkers, + /** + * True to include fluent `addKeyValue(...)` payloads. + */ + Boolean includeKeyValues, + /** + * True to include [LogEvent.sourceFile] / [LogEvent.sourceLine] when present. + */ + Boolean includeSource, + /** + * Field name for the timestamp. + */ + String timeKey, + /** + * Field name for the level. + */ + String levelKey, + /** + * Field name for the logger name. + */ + String loggerKey, + /** + * Field name for the rendered message. + */ + String messageKey, + /** + * Field name for MDC data. + */ + String mdcKey, + /** + * Field name for marker names. + */ + String markersKey, + /** + * Field name for structured exception data. + */ + String exceptionKey, + /** + * Field name for source metadata. + */ + String sourceKey, + ) { + + /** + * Default production-safe options. + */ + construct() { + construct JsonLogSinkOptions(Info, [], "***", + True, True, True, True, + "time", "level", "logger", "message", + "mdc", "markers", "exception", "source"); + } + + /** + * Convenience: custom root threshold. + */ + construct(Level rootLevel) { + construct JsonLogSinkOptions(rootLevel, [], "***", + True, True, True, True, + "time", "level", "logger", "message", + "mdc", "markers", "exception", "source"); + } + + /** + * Convenience: custom root threshold and redaction keys. + */ + construct(Level rootLevel, String[] redactedKeys) { + construct JsonLogSinkOptions(rootLevel, redactedKeys, "***", + True, True, True, True, + "time", "level", "logger", "message", + "mdc", "markers", "exception", "source"); + } + + /** + * True iff the supplied key should be replaced with [redaction]. + */ + Boolean redacts(String key) { + for (String candidate : redactedKeys) { + if (candidate == key) { + return True; + } + } + return False; + } +} diff --git a/lib_logging/src/main/x/logging/LogEvent.x b/lib_logging/src/main/x/logging/LogEvent.x index 1e3e447e1f..d09cfa4bd1 100644 --- a/lib_logging/src/main/x/logging/LogEvent.x +++ b/lib_logging/src/main/x/logging/LogEvent.x @@ -23,6 +23,11 @@ const LogEvent( Object[] arguments = [], Map mdcSnapshot = [], String threadName = "", + // Optional source metadata. The default `(Null, -1)` means "not captured". + // `Logger.logAt(...)` populates these fields explicitly; future compiler/runtime + // call-site capture should lower into that same API instead of changing sinks. + String? sourceFile = Null, + Int sourceLine = -1, // Structured key/value pairs accumulated through `LoggingEventBuilder.addKeyValue`. // Mirrors SLF4J 2.x's `KeyValuePair` list on `LoggingEvent`. Sinks that don't care // about structured payloads can ignore this field; sinks that do (JSON layouts, diff --git a/lib_logging/src/main/x/logging/LogSink.x b/lib_logging/src/main/x/logging/LogSink.x index 33508c0b4c..bf8611cb0d 100644 --- a/lib_logging/src/main/x/logging/LogSink.x +++ b/lib_logging/src/main/x/logging/LogSink.x @@ -6,8 +6,7 @@ * * `LogSink` is more like the Logback `Appender`: a single emission target with its own * level filter. Mapping multiple `LogSink`s onto one logger (Logback's "appender attached - * to logger" model) is the job of a future composite sink — see - * `doc/logging/future/logback-integration.md`. + * to logger" model) is the job of [CompositeLogSink]. * * The Service-Provider Interface for logging backends. A `LogSink` is the only thing a * `Logger` ever talks to; everything else (level checks, message formatting, marker @@ -17,9 +16,11 @@ * * Anything _above_ this interface is the public, stable API that user code depends on. * Anything _below_ this interface is replaceable: `ConsoleLogSink` for the default - * platform-controlled console output, `MemoryLogSink` in tests, a future `LogbackLogSink` - * for configuration-driven file/network appenders, a native sink wrapping `slf4j`+`logback` - * via the JIT bridge — all of those are interchangeable behind this interface. + * platform-controlled console output, `MemoryLogSink` in tests, [JsonLogSink] for + * structured JSON-Lines output, [CompositeLogSink] and [HierarchicalLogSink] for + * Logback-style backend policy, a future file/network sink, or an optional native sink + * wrapping `slf4j`+`logback` via the JIT bridge — all of those are interchangeable + * behind this interface. * * # Implementing a custom sink * @@ -30,7 +31,7 @@ * - `log` receives a fully-formed `LogEvent`. The message has already had `{}` placeholders * substituted; the `mdcSnapshot` has already been captured. * - * See `docs/custom-sinks.md` for a worked example. + * See `doc/logging/usage/custom-sinks.md` for worked examples. * * # Choosing between `const` and `service` for an implementation * @@ -43,8 +44,8 @@ * [ConsoleLogSink]. Cheap to construct; methods run on the caller's fiber. * - `service` — for sinks that carry mutable state shared across fibers: event * buffers, hit counters, file/socket writers, async worker queues. - * Examples: [MemoryLogSink], a future `FileLogSink`, a future - * `AsyncLogSink`. + * Examples: [MemoryLogSink], [HierarchicalLogSink], [AsyncLogSink], + * and a future `FileLogSink`. * * The full rule, with reference examples from the platform/xunit codebases (e.g. * `service ConsoleExecutionListener`, `service ErrorLog`), is in diff --git a/lib_logging/src/main/x/logging/Logger.x b/lib_logging/src/main/x/logging/Logger.x index 0c6fdf1a72..dd30181754 100644 --- a/lib_logging/src/main/x/logging/Logger.x +++ b/lib_logging/src/main/x/logging/Logger.x @@ -120,6 +120,21 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit at a runtime-chosen level with explicit source metadata. + * + * This mirrors the source-aware entry point on the slog-shaped library and gives a + * future compiler/runtime call-site capture feature a stable lowering target. Normal + * application code should keep using [log] / [info] / [atInfo] until such sugar exists. + */ + void logAt(Level level, + String message, + String sourceFile, + Int sourceLine, + Object[] arguments = [], + Exception? cause = Null, + Marker? marker = Null); + // ---- Fluent (SLF4J 2.x style) builder ------------------------------------------------------ /** diff --git a/lib_logging/src/test/x/LoggingTest.x b/lib_logging/src/test/x/LoggingTest.x index 858a241de7..aebcfb1cb8 100644 --- a/lib_logging/src/test/x/LoggingTest.x +++ b/lib_logging/src/test/x/LoggingTest.x @@ -13,6 +13,7 @@ * the xunit engine. */ module LoggingTest { + package json import json.xtclang.org; package logging import logging.xtclang.org; package xunit import xunit.xtclang.org; } diff --git a/lib_logging/src/test/x/LoggingTest/AsyncLogSinkTest.x b/lib_logging/src/test/x/LoggingTest/AsyncLogSinkTest.x new file mode 100644 index 0000000000..ecdaad8bb8 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/AsyncLogSinkTest.x @@ -0,0 +1,35 @@ +import logging.AsyncLogSink; +import logging.BasicLogger; +import logging.Logger; + +/** + * Tests the Logback-style async wrapper. + */ +class AsyncLogSinkTest { + + @Test + void shouldDrainQueuedEventsToDelegate() { + ListLogSink delegate = new ListLogSink(); + AsyncLogSink async = new AsyncLogSink(delegate, 10); + Logger logger = new BasicLogger("async.test", async); + + logger.info("queued"); + async.flush(); + + assert delegate.events.size == 1; + assert delegate.events[0].message == "queued"; + } + + @Test + void shouldDropEventsAfterCloseWithoutFlush() { + ListLogSink delegate = new ListLogSink(); + AsyncLogSink async = new AsyncLogSink(delegate, 10); + Logger logger = new BasicLogger("async.closed", async); + + async.close(flush=False); + logger.info("dropped"); + + assert async.droppedCount == 1; + assert delegate.events.empty; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/CompositeLogSinkTest.x b/lib_logging/src/test/x/LoggingTest/CompositeLogSinkTest.x new file mode 100644 index 0000000000..7ee9002fd4 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/CompositeLogSinkTest.x @@ -0,0 +1,39 @@ +import logging.BasicLogger; +import logging.CompositeLogSink; +import logging.Logger; + +/** + * Tests multi-appender fan-out. + */ +class CompositeLogSinkTest { + + @Test + void shouldFanOutToAllEnabledDelegates() { + ListLogSink first = new ListLogSink(); + ListLogSink second = new ListLogSink(); + Logger logger = new BasicLogger("composite.test", + new CompositeLogSink([first, second])); + + logger.warn("fanout"); + + assert first.events.size == 1; + assert second.events.size == 1; + assert first.events[0].message == "fanout"; + assert second.events[0].message == "fanout"; + } + + @Test + void shouldSkipDisabledDelegates() { + ListLogSink first = new ListLogSink(); + ListLogSink second = new ListLogSink(); + second.setLevel(logging.Level.Error); + + Logger logger = new BasicLogger("composite.filtered", + new CompositeLogSink([first, second])); + + logger.warn("only first"); + + assert first.events.size == 1; + assert second.events.empty; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/HierarchicalLogSinkTest.x b/lib_logging/src/test/x/LoggingTest/HierarchicalLogSinkTest.x new file mode 100644 index 0000000000..d2936f7efd --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/HierarchicalLogSinkTest.x @@ -0,0 +1,38 @@ +import logging.BasicLogger; +import logging.HierarchicalLogSink; +import logging.Logger; + +/** + * Tests Logback-style longest-prefix level configuration. + */ +class HierarchicalLogSinkTest { + + @Test + void shouldUseRootLevelWhenNoPrefixMatches() { + ListLogSink delegate = new ListLogSink(); + HierarchicalLogSink sink = new HierarchicalLogSink(delegate, logging.Level.Info); + Logger logger = new BasicLogger("other.component", sink); + + logger.debug("drop"); + logger.info("keep"); + + assert delegate.events.size == 1; + assert delegate.events[0].message == "keep"; + } + + @Test + void shouldUseLongestMatchingPrefix() { + ListLogSink delegate = new ListLogSink(); + HierarchicalLogSink sink = new HierarchicalLogSink(delegate, logging.Level.Warn); + Logger logger = new BasicLogger("payments.stripe.checkout", sink); + + sink.setLevel("payments", logging.Level.Info); + sink.setLevel("payments.stripe", logging.Level.Debug); + + logger.debug("debug stripe"); + logger.trace("drop trace"); + + assert delegate.events.size == 1; + assert delegate.events[0].message == "debug stripe"; + } +} diff --git a/lib_logging/src/test/x/LoggingTest/JsonLogSinkTest.x b/lib_logging/src/test/x/LoggingTest/JsonLogSinkTest.x new file mode 100644 index 0000000000..4d75c64532 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/JsonLogSinkTest.x @@ -0,0 +1,55 @@ +import json.Doc; +import json.JsonObject; +import json.Parser; + +import logging.BasicLogger; +import logging.JsonLogSink; +import logging.JsonLogSinkOptions; +import logging.Logger; + +/** + * Tests the production JSON sink's semantic document shape. + */ +class JsonLogSinkTest { + + @Test + void shouldRenderStructuredEventWithSourceAndRedaction() { + JsonLogSink sink = new JsonLogSink( + new JsonLogSinkOptions(logging.Level.Info, ["card"])); + ListLogSink capture = new ListLogSink(); + Logger logger = new BasicLogger("json.payment", capture); + + logger.atInfo() + .addKeyValue("requestId", "r_42") + .addKeyValue("card", "4111111111111111") + .log("charged"); + + JsonObject obj = parseObject(sink.render(capture.events[0])); + + assert obj["logger"] == "json.payment"; + assert obj["message"] == "charged"; + assert obj["requestId"] == "r_42"; + assert obj["card"] == "***"; + } + + @Test + void shouldRenderLogAtSourceMetadata() { + JsonLogSink sink = new JsonLogSink(); + ListLogSink list = new ListLogSink(); + Logger probe = new BasicLogger("json.source", list); + + probe.logAt(logging.Level.Warn, "slow", "PaymentService.x", 42); + + JsonObject obj = parseObject(sink.render(list.events[0])); + assert obj["source"].is(JsonObject); + JsonObject source = obj["source"].as(JsonObject); + assert source["file"] == "PaymentService.x"; + assert source["line"] == 42; + } + + private JsonObject parseObject(String rendered) { + Doc doc = new Parser(rendered.toReader()).parseDoc(); + assert doc.is(JsonObject); + return doc.as(JsonObject); + } +} diff --git a/lib_logging/src/test/x/LoggingTest/SourceLocationTest.x b/lib_logging/src/test/x/LoggingTest/SourceLocationTest.x new file mode 100644 index 0000000000..9a795d61a3 --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/SourceLocationTest.x @@ -0,0 +1,31 @@ +import logging.BasicLogger; +import logging.Logger; + +/** + * Tests explicit source-location capture in the SLF4J-shaped facade. + */ +class SourceLocationTest { + + @Test + void shouldPopulateSourceFieldsWhenUsingLogAt() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("source.test", sink); + + logger.logAt(logging.Level.Warn, "slow payment", "PaymentService.x", 77); + + assert sink.events.size == 1; + assert sink.events[0].sourceFile == "PaymentService.x"; + assert sink.events[0].sourceLine == 77; + } + + @Test + void shouldLeaveSourceFieldsEmptyForNormalLogCalls() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("source.normal", sink); + + logger.info("normal"); + + assert sink.events[0].sourceFile == Null; + assert sink.events[0].sourceLine == -1; + } +} diff --git a/lib_slogging/README.md b/lib_slogging/README.md index 54237b3d8c..4436245af4 100644 --- a/lib_slogging/README.md +++ b/lib_slogging/README.md @@ -4,8 +4,9 @@ Experimental Go `log/slog`-shaped structured logging library for Ecstasy. > **Status:** Working comparison POC. The core `Logger` / `Handler` / `Record` / > `Attr` / `Level` shape, text/JSON/no-op/memory handlers, derived loggers, groups, -> custom levels, source metadata, `LoggerContext`, runtime injection, and unit tests -> are in this branch. `JSONHandler` renders through `lib_json`. +> custom levels, source metadata, `LoggerContext`, runtime injection, async handler +> wrapping, handler options/redaction, and unit tests are in this branch. +> `JSONHandler` renders through `lib_json`. ## What this is diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index aeb7a882dc..71afcaa6c1 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -45,6 +45,8 @@ * The implementation side of that boundary contains: * - [TextHandler] — default human-readable text handler, writes via `@Inject Console` * - [JSONHandler] — JSON-Lines structured handler, rendered by `lib_json` + * - [HandlerOptions] — threshold, redaction, source, and field-name options + * - [AsyncHandler] — bounded async wrapper for slow handlers * - [BoundHandler] — derivation wrapper used by handlers that do not cache prefixes * - [NopHandler] — drops every record * - [MemoryHandler] — captures records in memory; useful in tests diff --git a/lib_slogging/src/main/x/slogging/AsyncHandler.x b/lib_slogging/src/main/x/slogging/AsyncHandler.x new file mode 100644 index 0000000000..9f1ad46f98 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/AsyncHandler.x @@ -0,0 +1,93 @@ +/** + * Async wrapper for a slog [Handler]. + * + * Records are fully constructed before they enter this handler, so delayed emission + * preserves attrs, groups, source metadata, and exceptions exactly as the caller + * produced them. + */ +service AsyncHandler(Handler delegate, Int capacity) + implements Handler { + + /** + * Convenience: bounded queue with room for 1024 records. + */ + construct(Handler delegate) { + construct AsyncHandler(delegate, 1024); + } + + private Record[] queue = new Record[]; + private Boolean draining = False; + private Boolean closed = False; + + /** + * Count of records dropped because the queue was full or closed. + */ + public/private Int droppedCount = 0; + + /** + * Current queue depth. Intended for tests and operational probes. + */ + @RO Int pending.get() = queue.size; + + @Override + Boolean enabled(Level level) { + return !closed && delegate.enabled(level); + } + + @Override + void handle(Record record) { + if (closed || queue.size >= capacity) { + ++droppedCount; + return; + } + + queue.add(record); + if (!draining) { + draining = True; + drain^(); + } + } + + @Override + Handler withAttrs(Attr[] attrs) { + return attrs.empty ? this : new AsyncHandler(delegate.withAttrs(attrs), capacity); + } + + @Override + Handler withGroup(String name) { + return name == "" ? this : new AsyncHandler(delegate.withGroup(name), capacity); + } + + /** + * Synchronously drain currently queued records. + */ + void flush() { + drain(); + } + + /** + * Stop accepting new records. By default this drains pending records first. + */ + void close(Boolean flush = True) { + closed = True; + if (flush) { + drain(); + } else { + droppedCount += queue.size; + queue.clear(); + draining = False; + } + } + + /** + * Drain the queue on this service's fiber. + */ + private void drain() { + while (!queue.empty) { + Record record = queue[0]; + queue.delete(0); + delegate.handle(record); + } + draining = False; + } +} diff --git a/lib_slogging/src/main/x/slogging/Handler.x b/lib_slogging/src/main/x/slogging/Handler.x index 1f70e93713..bcb402b1a6 100644 --- a/lib_slogging/src/main/x/slogging/Handler.x +++ b/lib_slogging/src/main/x/slogging/Handler.x @@ -28,8 +28,8 @@ * - `const` — for stateless forwarders / pure adapters. Examples: `TextHandler`, * `NopHandler`, `JSONHandler`. Construction-time configuration only. * - `service` — for handlers that carry mutable state shared across fibers. Examples: - * `MemoryHandler` (collects records), a future `FileHandler` (owns a - * writer), a future `AsyncHandler` (owns a worker queue). + * `MemoryHandler` (collects records), `AsyncHandler` (owns a worker + * queue), and a future `FileHandler` (owns a writer). * * `Logger` is a `const` and references its `handler` through this interface, so every * concrete `Handler` must be `Passable` (`immutable` or a `service`). The full rule and diff --git a/lib_slogging/src/main/x/slogging/HandlerOptions.x b/lib_slogging/src/main/x/slogging/HandlerOptions.x new file mode 100644 index 0000000000..613d327873 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/HandlerOptions.x @@ -0,0 +1,73 @@ +/** + * Shared production options for text/JSON handlers. + * + * The slog API keeps caller semantics in `Logger`/`Record`/`Attr`; formatting policy + * belongs in handlers. This const captures the first production knobs without turning + * the POC into a full configuration system. + */ +const HandlerOptions( + /** + * Lowest level a record must have to pass the handler. + */ + Level rootLevel, + /** + * Attribute keys whose values should be replaced with [redaction]. + */ + String[] redactedKeys, + /** + * Replacement value emitted for redacted attributes. + */ + String redaction, + /** + * True to render `Record.sourceFile` / `sourceLine` when present. + */ + Boolean includeSource, + /** + * Field name for the timestamp. + */ + String timeKey, + /** + * Field name for the level label. + */ + String levelKey, + /** + * Field name for the human message. + */ + String messageKey, + /** + * Field name for source metadata. + */ + String sourceKey, + /** + * Field name for structured exception data. + */ + String exceptionKey, + ) { + + construct() { + construct HandlerOptions(Level.Info, [], "***", True, + "time", "level", "msg", "source", "exception"); + } + + construct(Level rootLevel) { + construct HandlerOptions(rootLevel, [], "***", True, + "time", "level", "msg", "source", "exception"); + } + + construct(Level rootLevel, String[] redactedKeys) { + construct HandlerOptions(rootLevel, redactedKeys, "***", True, + "time", "level", "msg", "source", "exception"); + } + + /** + * True iff the supplied key should be rendered as [redaction]. + */ + Boolean redacts(String key) { + for (String candidate : redactedKeys) { + if (candidate == key) { + return True; + } + } + return False; + } +} diff --git a/lib_slogging/src/main/x/slogging/JSONHandler.x b/lib_slogging/src/main/x/slogging/JSONHandler.x index 284425778b..0f601da3fd 100644 --- a/lib_slogging/src/main/x/slogging/JSONHandler.x +++ b/lib_slogging/src/main/x/slogging/JSONHandler.x @@ -11,21 +11,28 @@ import json.Printer; * source metadata is emitted under `"source"`, and exceptions are represented * structurally. */ -const JSONHandler(Level rootLevel, String groupPrefix) +const JSONHandler(HandlerOptions options, String groupPrefix) implements Handler { /** * No-arg convenience. See parallel comment on `lib_slogging.TextHandler`. */ construct() { - construct JSONHandler(Level.Info, ""); + construct JSONHandler(new HandlerOptions(), ""); } /** * Single-arg convenience: configurable threshold, no group prefix. */ construct(Level rootLevel) { - construct JSONHandler(rootLevel, ""); + construct JSONHandler(new HandlerOptions(rootLevel), ""); + } + + /** + * Production-options convenience. + */ + construct(HandlerOptions options) { + construct JSONHandler(options, ""); } @Inject Console console; @@ -35,7 +42,7 @@ const JSONHandler(Level rootLevel, String groupPrefix) */ @Override Boolean enabled(Level level) { - return level.severity >= rootLevel.severity; + return level.severity >= options.rootLevel.severity; } /** @@ -60,24 +67,26 @@ const JSONHandler(Level rootLevel, String groupPrefix) */ JsonObject toJson(Record record) { JsonObject obj = json.newObject(); - obj.put("time", record.time.toString()); - obj.put("level", record.level.label); - obj.put("msg", record.message); + obj.put(options.timeKey, record.time.toString()); + obj.put(options.levelKey, record.level.label); + obj.put(options.messageKey, record.message); JsonObject attrTarget = groupPrefix == "" ? obj : ensureObject(obj, groupPrefix); addAttrs(attrTarget, record.attrs); if (Exception e ?= record.exception) { - obj.put("exception", exceptionJson(e)); + obj.put(options.exceptionKey, exceptionJson(e)); } - if (String file ?= record.sourceFile) { - JsonObject source = json.newObject(); - source.put("file", file); - if (record.sourceLine >= 0) { - source.put("line", record.sourceLine.toIntLiteral()); + if (options.includeSource) { + if (String file ?= record.sourceFile) { + JsonObject source = json.newObject(); + source.put("file", file); + if (record.sourceLine >= 0) { + source.put("line", record.sourceLine.toIntLiteral()); + } + obj.put(options.sourceKey, source.makeImmutable()); } - obj.put("source", source.makeImmutable()); } if (record.threadName != "") { @@ -110,7 +119,7 @@ const JSONHandler(Level rootLevel, String groupPrefix) */ private void addAttrs(JsonObject obj, Attr[] attrs) { for (Attr a : attrs) { - obj.put(a.key, attrValue(a.value)); + obj.put(a.key, options.redacts(a.key) ? options.redaction : attrValue(a.value)); } } diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index 0a16a2b0b1..73a2248b83 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -8,20 +8,13 @@ * * If the record carries an exception, it is rendered on the following line. * - * # POC status - * - * The implementation here is intentionally minimal — enough to demonstrate the slog - * shape end to end (`Logger.with(...)`, attribute folding into namespaced keys, level - * threshold filtering) but not yet a fully configurable formatter. Specifically, it has - * fixed timestamp/message formatting and no handler options object yet. - * * # Why this handler is a `const`, not a `service` * * Same rule as `lib_logging`'s `ConsoleLogSink`: stateless forwarder over `@Inject * Console`. Threshold fixed at construction. No accumulation, no shared mutable state. * See `doc/logging/design/design.md` ("Sink type: `const` vs `service`") for the full rule. */ -const TextHandler(Level rootLevel, String groupPrefix) +const TextHandler(HandlerOptions options, String groupPrefix) implements Handler { /** @@ -30,14 +23,21 @@ const TextHandler(Level rootLevel, String groupPrefix) * instead of default-arg synthesis. */ construct() { - construct TextHandler(Level.Info, ""); + construct TextHandler(new HandlerOptions(), ""); } /** * Single-arg convenience: configurable threshold, no group prefix. */ construct(Level rootLevel) { - construct TextHandler(rootLevel, ""); + construct TextHandler(new HandlerOptions(rootLevel), ""); + } + + /** + * Production-options convenience. + */ + construct(HandlerOptions options) { + construct TextHandler(options, ""); } @Inject Console console; @@ -48,7 +48,7 @@ const TextHandler(Level rootLevel, String groupPrefix) */ @Override Boolean enabled(Level level) { - return level.severity >= rootLevel.severity; + return level.severity >= options.rootLevel.severity; } /** @@ -71,6 +71,16 @@ const TextHandler(Level rootLevel, String groupPrefix) renderAttr(buf, groupPrefix, a); } + if (options.includeSource) { + if (String file ?= record.sourceFile) { + buf.append(" source=") + .append(file); + if (record.sourceLine >= 0) { + buf.append(':').append(record.sourceLine); + } + } + } + console.print(buf.toString()); if (Exception e ?= record.exception) { @@ -106,7 +116,8 @@ const TextHandler(Level rootLevel, String groupPrefix) renderAttr(buf, key, child); } } else { - buf.append(key).append('=').append(a.value.toString()); + buf.append(key).append('=') + .append(options.redacts(a.key) ? options.redaction : a.value.toString()); } } diff --git a/lib_slogging/src/test/x/SLoggingTest/AsyncHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/AsyncHandlerTest.x new file mode 100644 index 0000000000..8d117a651b --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/AsyncHandlerTest.x @@ -0,0 +1,34 @@ +import slogging.AsyncHandler; +import slogging.Logger; + +/** + * Tests the slog-shaped async handler wrapper. + */ +class AsyncHandlerTest { + + @Test + void shouldDrainQueuedRecordsToDelegate() { + ListHandler delegate = new ListHandler(); + AsyncHandler async = new AsyncHandler(delegate, 10); + Logger logger = new Logger(async); + + logger.info("queued"); + async.flush(); + + assert delegate.records.size == 1; + assert delegate.records[0].message == "queued"; + } + + @Test + void shouldDropRecordsAfterCloseWithoutFlush() { + ListHandler delegate = new ListHandler(); + AsyncHandler async = new AsyncHandler(delegate, 10); + Logger logger = new Logger(async); + + async.close(flush=False); + logger.info("dropped"); + + assert async.droppedCount == 1; + assert delegate.records.empty; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x index e3af3451f0..69bff1f730 100644 --- a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x @@ -3,6 +3,7 @@ import json.JsonObject; import json.Parser; import slogging.Attr; +import slogging.HandlerOptions; import slogging.JSONHandler; import slogging.Level; import slogging.Record; @@ -67,6 +68,23 @@ class JSONHandlerTest { assert payments["amount"] == 1099; } + @Test + void shouldApplyHandlerOptionsRedaction() { + JSONHandler handler = new JSONHandler( + new HandlerOptions(Level.Info, ["token"])); + Record record = new Record( + time = new Time("2019-05-22T120123.456Z"), + message = "auth", + level = Level.Info, + attrs = [Attr.of("token", "secret"), Attr.of("user", "u_1")], + ); + + JsonObject obj = parseObject(handler.render(record)); + + assert obj["token"] == "***"; + assert obj["user"] == "u_1"; + } + private JsonObject parseObject(String rendered) { Doc doc = new Parser(rendered.toReader()).parseDoc(); assert doc.is(JsonObject); diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x index b8dbf217aa..f7c7b10535 100644 --- a/manualTests/src/main/x/TestLogger.x +++ b/manualTests/src/main/x/TestLogger.x @@ -80,11 +80,9 @@ module TestLogger { } /** - * Default-logger injection. The runtime registers exactly one supplier under the - * resource name `"logger"` (see `NativeContainer.initResources`), so this is the only - * spelling that resolves: the field name `logger` matches the registered resource - * name. Any other field name — `@Inject Logger payments;` — would not resolve, - * by design. + * Default-logger injection. The runtime registers the familiar `"logger"` resource + * name and, for canonical `logging.Logger`, falls back to a BasicLogger named from + * the caller namespace when the compiler supplied only that default field name. */ void runInjected() { @Inject Logger logger; From 200428abd9f3457d537849b4d2ff2e1f66b46748 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 09:37:12 +0200 Subject: [PATCH 12/28] Mark logging injection as POC wiring --- javatools/src/main/java/org/xvm/runtime/NativeContainer.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index 1560aeb40e..16f2d681de 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -380,6 +380,9 @@ private void initResources(ConstantPool pool) { addResourceSupplier(new InjectionKey("hash", typeHashCollector), templateHashCollector::ensureCollector); // +++ logging.Logger, logging.MDC, and slogging.Logger + // TODO: This is not intended as permanent code. It is a hack for the POC of + // injected SLF4J and slog logging and will likely be implemented differently + // and in a more robust manner for the real logging implementation in the XDK. // These back `@Inject Logger logger;`, `@Inject MDC mdc;`, and // `@Inject slogging.Logger logger;` on the user side. The two logger resources use // the same resource name, so getInjectable() must resolve by both name and type. @@ -823,6 +826,8 @@ public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, } } + // TODO: Part of the injected logging POC hack above; the final XDK logging + // injector should resolve default logger names through a more robust mechanism. if (isCanonicalLoggingLogger(type)) { return ensureLogger(frame, m_typeBasicLogger, inferLoggerName(frame, sName)); } From a1862b1de6a83bada6fa0c4c8d778e8a1bdeaf00 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 09:46:30 +0200 Subject: [PATCH 13/28] Clarify logging POC requirements and configuration --- doc/logging/README.md | 34 ++- doc/logging/api-cross-reference.md | 16 ++ doc/logging/lib-logging-vs-lib-slogging.md | 151 +++++++++- doc/logging/usage/configuration.md | 313 +++++++++++++++++++++ doc/logging/usage/slog-parity.md | 18 ++ doc/logging/usage/structured-logging.md | 250 ++++++++++++++-- lib_logging/README.md | 3 + lib_slogging/README.md | 5 + 8 files changed, 762 insertions(+), 28 deletions(-) create mode 100644 doc/logging/usage/configuration.md diff --git a/doc/logging/README.md b/doc/logging/README.md index 55e3c25244..9ed0db393e 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -5,6 +5,34 @@ so reviewers can compare two familiar industry shapes in real Ecstasy code. The branch now recommends `lib_logging` as the canonical API shape and keeps `lib_slogging` as the comparison/reference POC. +The two libraries coexist **only for this POC**. Application and XDK code should use +one logging shape or the other, not both in the same design. Once the XDK settles on an +injectable logging model, the winning API should be the XDK's `lib_logging`; the losing +POC should either disappear or become an explicitly named adapter/comparison module. + +## Requirements + +The accepted XDK logging API should satisfy these requirements: + +- **One canonical facade:** one injectable logging API, published as `lib_logging`. +- **Familiar call shape:** instantly recognizable to either SLF4J/Logback or slog users, + with migration examples for Java/Go teams. +- **Injection-first acquisition:** `@Inject Logger logger;` lets the host/container own + backend policy; library code must not choose global process logging. +- **Structured events:** first-class key/value fields, context fields, exceptions, + markers/categories where applicable, and JSON/cloud output without parsing messages. +- **Dynamically pluggable backends:** sinks/handlers can be swapped, composed, wrapped + with async queues, and replaced by a host-controlled reload service. +- **Logback-equivalent operations:** root level, per-logger/category overrides, + multi-destination fanout, async output, JSON/text formatting, redaction, and + configuration-file reload. +- **Production safety:** backend failures should not break callers; redaction, + output destinations, formatting knobs, and shutdown/flush behavior must be explicit. +- **Testability:** in-memory capture sinks/handlers and contract tests for custom + backends. +- **Runtime/compiler path:** default logger naming and source-location capture should + have a clear lowering target even if full compiler sugar lands later. + Both libraries use the same XDK-facing architecture: application code receives an injected `Logger`, the container owns the backend, and production behavior is provided by replacing a `LogSink` or `Handler`. Both can carry structured fields @@ -22,7 +50,10 @@ place. ## Recommendation -Choose **`lib_logging`** as the canonical XDK logging facade. It gives Ecstasy the +Choose **`lib_logging`** as the canonical XDK logging facade. If reviewers choose the +slog shape instead, it should still graduate under the canonical `lib_logging` name; +`lib_slogging` is a POC name, not a proposed permanent sibling. The current +recommendation is the SLF4J-shaped implementation because it gives Ecstasy the SLF4J/Logback-shaped surface that JVM users recognize immediately: named loggers, `{}` formatting, markers, MDC, fluent event builders, and a `LogSink` backend boundary. The slog-shaped library remains useful review material and an adapter @@ -68,6 +99,7 @@ you only read one file. The rest of the tree is organized by reader: | [`usage/custom-sinks.md`](usage/custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | | [`usage/custom-handlers.md`](usage/custom-handlers.md) | How to write a custom slog `Handler`, with worked examples. | | [`usage/structured-logging.md`](usage/structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | +| [`usage/configuration.md`](usage/configuration.md) | Logback XML vs proposed Ecstasy JSON configuration, dynamic sink/handler reload, and equivalence mapping. | | **Follow-up backend/compiler work** | | | [`future/logback-integration.md`](future/logback-integration.md) | Configuration-driven Logback-style backend on top of the shipped primitives. | | [`future/native-bridge.md`](future/native-bridge.md) | Optional Java Logback bridge — feasibility analysis and why it is not the default path. | diff --git a/doc/logging/api-cross-reference.md b/doc/logging/api-cross-reference.md index 09bf0fdee6..6f3e95c710 100644 --- a/doc/logging/api-cross-reference.md +++ b/doc/logging/api-cross-reference.md @@ -17,6 +17,10 @@ Primary references: [`Level`](https://www.slf4j.org/api/org/slf4j/event/Level.html), [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html). - Go `log/slog` package docs: [`log/slog`](https://pkg.go.dev/log/slog). +- Related Java structured/fluent APIs: + [`Log4j 2 fluent API`](https://logging.apache.org/log4j/2.x/manual/logbuilder.html), + [`Google Flogger`](https://google.github.io/flogger/), and + [`logstash-logback-encoder`](https://github.com/logfellow/logstash-logback-encoder). ## SLF4J-shaped `lib_logging` @@ -54,6 +58,18 @@ Primary references: | [`AsyncHandler`](../../lib_slogging/src/main/x/slogging/AsyncHandler.x) | Go async handler wrappers are usually third-party; same role as Logback `AsyncAppender` | Bounded queue wrapper around any handler, preserving already-constructed records. | Implemented as an Ecstasy `service` and keeps `withAttrs` / `withGroup` derivation by wrapping derived delegates. | | [`MemoryHandler`](../../lib_slogging/src/main/x/slogging/MemoryHandler.x), [`NopHandler`](../../lib_slogging/src/main/x/slogging/NopHandler.x) | Go test/discard handler patterns | Capture records for assertions or drop records entirely. | Small Ecstasy convenience handlers; not direct Go stdlib types. | +## No slog counterpart for logger categories or markers + +Go `slog.Logger.WithGroup` is grouped attribute namespacing, not a named logger +hierarchy. It lets a handler qualify output keys (for example `payments.amount` in text +or `"payments": {"amount": ...}` in JSON), but it does not create a logger named +`com.acme.payments` that a backend can route with Logback's longest-prefix rule. + +Go `slog` also has no `Marker` / `MarkerFactory` equivalent. The slog equivalent is an +attribute such as `slog.Bool("audit", true)`. That is simpler and often preferable for +JSON output, but it does not carry SLF4J marker identity, marker containment, or +marker-aware enabled checks. + ## No slog counterpart for `MessageFormatter` `lib_logging.MessageFormatter` intentionally has no `lib_slogging` equivalent. SLF4J diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index 32e442689a..1cce3e3182 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -3,6 +3,11 @@ Two parallel XDK logging libraries model the same end-to-end capability but follow different prior-art traditions. +They coexist only so this branch can compare the designs. User code should choose one +shape, not mix both as permanent XDK APIs. After review, the chosen injectable logging +shape should graduate as `lib_logging`; `lib_slogging` is a POC name for the Go +`log/slog`-shaped candidate, not a proposal to keep a second default logging facade. + | Library | Prior art | Core shape | |---|---|---| | `lib_logging` | SLF4J 2.x + Logback | Named loggers, `{}` message formatting, MDC, markers, fluent event builders, and a `LogSink` backend boundary. | @@ -114,7 +119,132 @@ Both produce equivalent structured output. The shapes diverge in how --- -## 3. Per-axis analysis +## 3. Exists / Missing Matrix + +This is the blunt version of the comparison. "Missing" does not mean impossible; it +means the concept is not first-class in that API and must be modeled with a more general +mechanism such as attributes or a custom backend. + +| Capability | SLF4J / Logback | Go `log/slog` | `lib_logging` | `lib_slogging` | Why it matters | +|---|---|---|---|---|---| +| Hierarchical logger names | **Exists.** `LoggerFactory.getLogger(PaymentService.class)`, Logback ``. | **No first-class logger name hierarchy.** `Logger.With` and `WithGroup` attach attrs / qualify attr keys; they do not define a logger category string with longest-prefix routing. | **Exists.** `logger.named("com.acme.payments")` plus `HierarchicalLogSink.setLevel(...)`. | **Modeled as attrs/groups.** Use `logger.with([Attr.of("component", "...")])` or `withGroup("payments")`; custom handlers can filter attrs, but there is no built-in prefix tree. | Per-package/per-module level control is an operational staple in Logback configs. | +| Markers | **Exists.** `Marker`, `MarkerFactory`, marker-aware level checks, Logback marker filters. | **No Marker type.** Use attrs such as `"audit"=true` or `"category"="audit"`. | **Exists.** `Marker`, marker DAG, multi-marker fluent events, marker-aware sinks. | **Modeled as attrs.** `Attr.of("audit", True)` is simpler but has no marker containment semantics. | Markers are useful for audit/security routing that should not depend on message text. | +| Message templates | **Exists.** `"processed {}"` with argument arrays and trailing throwable promotion. | **Not the model.** Message is a stable string; variable data is attrs. | **Exists.** `MessageFormatter` implements SLF4J-style `{}` formatting. | **Intentionally absent.** Use `logger.info("processed", [Attr.of("count", count)])`. | SLF4J favors migration familiarity; slog favors structured data over interpolation. | +| Mapped context | **Exists.** `MDC.put(...)`; Logback layouts can render it. | **Different shape.** Context-aware methods accept `context.Context`; persistent fields usually come from `Logger.With(...)`. | **Exists.** `@Inject MDC` backed by `SharedContext`. | **Different shape.** `Logger.with(...)` is explicit; `LoggerContext` is an optional `SharedContext` helper. | Request IDs and tenant/user IDs should not be threaded through every logging call by hand. | +| Open custom levels | **No.** SLF4J has the standard fixed ladder. | **Exists.** `slog.Level` is an integer; `Level(2)` is valid. | **No.** Closed enum plus `Off`. | **Exists.** `new Level(2, "NOTICE")`. | Useful for environments with `NOTICE`, `CRITICAL`, or domain-specific levels; less canonical for generic tooling. | +| Handler derivation hooks | **No direct facade equivalent.** Backends configure appenders/loggers; event builders carry per-event data. | **Exists.** `Handler.WithAttrs` and `WithGroup` let handlers pre-resolve context. | **No direct equivalent.** `LogSink` stays two-method and relies on logger names/MDC/builders. | **Exists.** `Handler.withAttrs` / `withGroup`, with `BoundHandler` default semantics. | Helps high-volume structured handlers avoid recomputing prefixes per record. | +| Source location | **Backend-dependent.** Logback can calculate caller data, but SLF4J does not make source a normal facade argument. | **Exists in the record model.** `Record.PC`, `Record.Source()`, `HandlerOptions.AddSource`. | **Explicit API.** `Logger.logAt(...)` populates `LogEvent.sourceFile` / `sourceLine`; compiler capture is future work. | **Explicit API.** `Logger.logAt(...)` populates `Record.sourceFile` / `sourceLine`; compiler capture is future work. | Useful for debugging, but automatic capture has runtime/compiler cost. | +| Attribute replacement / redaction | **Backend concern.** Logback filters/encoders decide. | **Exists in `HandlerOptions.ReplaceAttr`.** | **Exists in shipped JSON sink options.** `JsonLogSinkOptions.redactedKeys`. | **Exists in POC options.** `HandlerOptions.redactedKeys`. | Production JSON output needs redaction without caller code remembering every sink policy. | + +### Examples for the two contentious claims + +**Hierarchical category routing exists in SLF4J / Logback and `lib_logging`:** + +```java +// Java + SLF4J +private static final Logger LOG = + LoggerFactory.getLogger("com.acme.payments.stripe"); +LOG.debug("charged {}", paymentId); +``` + +```xml + + + +``` + +```ecstasy +// Ecstasy + lib_logging +HierarchicalLogSink sink = new HierarchicalLogSink(new ConsoleLogSink(), Info); +sink.setLevel("com.acme.payments", Debug); + +Logger log = new BasicLogger("root", sink) + .named("com.acme.payments.stripe"); +log.debug("charged {}", [paymentId]); +``` + +Go `slog` can carry a category, but it is data, not a built-in logger hierarchy: + +```go +// Go + slog +log := slog.New(handler).With("component", "com.acme.payments.stripe") +log.Debug("charged", "paymentId", paymentID) +``` + +```ecstasy +// Ecstasy + lib_slogging +SLogger log = logger.with([ + Attr.of("component", "com.acme.payments.stripe"), +]); +log.debug("charged", [Attr.of("paymentId", paymentId)]); +``` + +That is perfectly workable for structured output. What it does not give by itself is +Logback's standard longest-prefix rule: "turn on DEBUG for `com.acme.payments` and all +children." A slog handler can implement attr-based filtering, but it is not the same +standard category mechanism. + +**Markers exist in SLF4J / Logback and `lib_logging`; slog uses attrs instead:** + +```java +// Java + SLF4J +Marker audit = MarkerFactory.getMarker("AUDIT"); +LOG.info(audit, "user signed in"); +``` + +```xml + + + AUDIT + ACCEPT + DENY + +``` + +```ecstasy +// Ecstasy + lib_logging +Marker audit = MarkerFactory.getMarker("AUDIT"); +logger.info("user signed in", marker=audit); +``` + +```go +// Go + slog: model the same signal as an attribute +logger.Info("user signed in", "audit", true) +``` + +```ecstasy +// Ecstasy + lib_slogging +logger.info("user signed in", [Attr.of("audit", True)]); +``` + +The attr version is simpler and often enough. The marker version has marker identity, +marker containment (`SECURITY` can contain `AUDIT`), marker-aware enabled checks, and +direct compatibility with existing Logback marker filters. + +### Are Java APIs moving toward slog? + +They are moving toward the same structured/fluent direction, but they are not the same +API shape. + +| Java API / framework | Looks slog-like because | Still differs from Go slog | +|---|---|---| +| **SLF4J 2.x fluent API** | `logger.atInfo().addKeyValue("k", v).log("msg")` gives a message plus structured fields, close to slog's `Info("msg", "k", v)`. | Keeps SLF4J concepts: named loggers, `{}` message templates, markers, MDC, and backend binding through Logback/other providers. | +| **Log4j 2 API** | Has `LOGGER.atInfo().withMarker(...).withThrowable(...).withLocation().log(...)`, custom levels, Thread Context, JSON layouts, and rich message types. | It is a broader Java logging API, not an attr-only slog model. Markers, logger names, message factories, and config/plugins remain first-class. | +| **Google Flogger** | Uses a fluent API: `logger.atInfo().withCause(e).log("value: %s", value)` and supports lazy/rate-limited logging. | It is optimized for self-documenting Java call sites and performance, not Go's `Handler` / `Attr` / `Record` architecture. | +| **Logback + logstash-logback-encoder / structured arguments** | Many teams keep SLF4J and add structured fields at the call site plus a JSON encoder at the backend. | The facade remains SLF4J/Logback-shaped; structured data is added to that ecosystem rather than replacing it with slog semantics. | + +So the designs are not completely different things. They are two answers to the same +industry pressure: log events need structured fields. Go made attrs the central model. +Modern Java mostly retrofitted structured/fluent APIs onto the existing logger-name, +marker, MDC, appender, and configuration ecosystem. + +That is why `lib_logging` can be both SLF4J-shaped and modern: it keeps the Java +operational model but includes SLF4J 2-style `addKeyValue`, JSON sinks, redaction, and +async/composite/hierarchical backend primitives. + +--- + +## 4. Per-axis analysis ### 3.1 Levels — closed enum vs open integer @@ -566,7 +696,7 @@ audience that will overlap heavily with both. --- -## 4. Ecstasy idiom fit — where the languages bite +## 5. Ecstasy idiom fit — where the languages bite ### 4.1 `const` vs `service` for the building blocks @@ -638,7 +768,7 @@ lands. --- -## 5. What we want reviewer feedback on +## 6. What we want reviewer feedback on 1. **`MDC` worth keeping?** SLF4J's per-fiber MDC is the most opinionated piece of this comparison. It costs ~50 lines of `SharedContext` machinery + a small @@ -674,17 +804,18 @@ lands. libraries demonstrate a non-injection idiom (a top-level `Logger.default` const, say) for comparison? -8. **Source location.** slog captures it; SLF4J doesn't. Is this a v0 concern in - Ecstasy or can we defer to a follower release? +8. **Source location.** Both POCs expose explicit `logAt(...)` APIs. Do reviewers want + compiler/runtime sugar that automatically populates those fields on ordinary + `logger.info(...)` calls? 9. **Performance baseline.** Neither library has been profiled. If reviewers want benchmarks before deciding, name the workload (sustained 100k events/sec to a no-op sink? burst with disabled levels? 95th-percentile latency at the call site?) and we'll add it to the comparison. -10. **Async / batching home.** Both libraries punt async to a follower module. Is - that the right call, or should one of the two ship a default async wrapper to - make "log to network" easier out of the box? +10. **Configuration home.** Both libraries now have async wrappers. Should the XDK + also ship a standard JSON config loader/reload service, or leave config-file + loading to applications/backends? 11. **Should Ecstasy ship two logging libraries permanently?** The Java ecosystem has two large facades (SLF4J, Log4j 2 API) and is by no means ergonomic for it. @@ -694,7 +825,7 @@ lands. --- -## 6. Recommendation +## 7. Recommendation Choose **`lib_logging` as the canonical Ecstasy logging library**. @@ -732,7 +863,7 @@ names, and optional backend configuration loaders can all target the existing --- -## 7. What lives in this PR +## 8. What lives in this PR This branch (`lagergren/logging`) contains two comparable implementations and the review material needed to choose between them. diff --git a/doc/logging/usage/configuration.md b/doc/logging/usage/configuration.md new file mode 100644 index 0000000000..0107d4be63 --- /dev/null +++ b/doc/logging/usage/configuration.md @@ -0,0 +1,313 @@ +# Logging configuration and dynamic backends + +Logging configuration belongs below the facade. Application/library code should keep +using `@Inject Logger logger;`; the host/container decides which sinks or handlers are +active, where output goes, which fields are redacted, and which logger categories are +debug-enabled. + +This is exactly the SLF4J/Logback split: SLF4J is the facade, Logback is the configured +backend. The Ecstasy shape should preserve that split. + +## Java Logback baseline + +A typical Logback config combines: + +- appenders: where output goes; +- layouts/encoders: how events are rendered; +- loggers: category-specific level and appender rules; +- root: default level and destinations; +- scan/reload: optional config-file hot reload. + +```xml + + + + %d %-5level [%thread] %logger - %msg %kvp%n + + + + + logs/app.jsonl + + + + + 8192 + + + + + + + + + + + + +``` + +The important point is that caller code does not mention any of this. It just logs. + +## Proposed Ecstasy JSON shape + +Ecstasy does not need to copy Logback XML. A JSON or Ecstasy-native config object is a +better fit for the XDK because the backend graph is already typed and programmatic. + +```json +{ + "version": 1, + "logging": { + "rootLevel": "Info", + "sinks": [ + { + "name": "console", + "type": "console", + "format": "text" + }, + { + "name": "json", + "type": "json", + "destination": {"type": "file", "path": "logs/app.jsonl"}, + "redactedKeys": ["authorization", "password", "token"], + "fields": { + "time": "time", + "level": "severity", + "logger": "logger", + "message": "message", + "mdc": "mdc", + "markers": "markers", + "exception": "exception", + "source": "source" + } + }, + { + "name": "async-json", + "type": "async", + "delegate": "json", + "capacity": 8192, + "onOverflow": "drop-newest" + } + ], + "loggers": [ + { + "name": "com.acme.payments", + "level": "Debug", + "sinks": ["async-json", "console"] + } + ] + } +} +``` + +This is the same information as the Logback XML, but the names line up with XDK +objects: sinks, levels, destinations, redaction, field names, and logger-name routing. + +## How this maps to `lib_logging` + +The current POC already has the backend building blocks: + +| Config concept | POC type | +|---|---| +| Console appender | `ConsoleLogSink` | +| JSON encoder / JSON appender | `JsonLogSink` + `JsonLogSinkOptions` | +| Async appender | `AsyncLogSink` | +| Multiple appenders | `CompositeLogSink` | +| Per-logger level tree | `HierarchicalLogSink` | +| Test/list appender | `MemoryLogSink` | + +A config loader can build the sink graph from JSON: + +```ecstasy +LogSink console = new ConsoleLogSink(Info); + +LogSink json = new JsonLogSink(new JsonLogSinkOptions( + Info, + ["authorization", "password", "token"])); + +LogSink asyncJson = new AsyncLogSink(json, 8192); + +LogSink paymentsFanout = new CompositeLogSink([asyncJson, console]); + +HierarchicalLogSink configured = new HierarchicalLogSink(paymentsFanout, Info); +configured.setLevel("com.acme.payments", Debug); +``` + +Dynamic reload should be handled by a stable service owned by the host container. The +shape is: + +```ecstasy +service ReloadableLogSink(LogSink initial) + implements LogSink { + private LogSink current = initial; + + void replace(LogSink next) { + current = next; + } + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return current.isEnabled(loggerName, level, marker); + } + + @Override + void log(LogEvent event) { + current.log(event); + } +} +``` + +The runtime injects `BasicLogger(..., reloadable)`. When `logging.json` changes, the +host parses the new file, builds a new `LogSink` graph, and calls `reloadable.replace`. +Existing injected loggers keep working because their sink object is stable. + +Adding a sink dynamically is the same operation: build a new graph containing the new +sink and swap it into the reloadable wrapper. A richer `ConfiguredLogSink` service could +also expose direct methods: + +```ecstasy +configured.addSink("audit-json", new AsyncLogSink(new JsonLogSink(auditOptions))); +configured.attachSink("com.acme.audit", "audit-json"); +configured.setLevel("com.acme.audit", Info); +``` + +Those methods belong in a future configuration module, not in the caller-facing +`Logger` API. + +## How this maps to Go slog / `lib_slogging` + +Go `log/slog` does not define a standard configuration-file format comparable to +`logback.xml`. Go programs usually construct a `Handler` graph from application config +or environment variables. The standard API provides `Handler`, `HandlerOptions`, +`TextHandler`, `JSONHandler`, `WithAttrs`, and `WithGroup`; configuration is ordinary Go +code around those pieces. + +The equivalent Ecstasy JSON for the slog-shaped POC would configure handlers instead of +sinks: + +```json +{ + "version": 1, + "slogging": { + "handler": { + "type": "async", + "capacity": 8192, + "delegate": { + "type": "json", + "rootLevel": "Info", + "redactedKeys": ["authorization", "password", "token"], + "includeSource": true, + "fields": { + "time": "time", + "level": "severity", + "message": "message", + "source": "source", + "exception": "exception" + } + } + }, + "context": { + "attrs": [ + {"key": "service", "value": "payments"} + ] + } + } +} +``` + +Programmatic construction looks like this: + +```ecstasy +Handler json = new JSONHandler(new HandlerOptions( + Level.Info, + ["authorization", "password", "token"])); + +Handler async = new AsyncHandler(json, 8192); +Logger logger = new Logger(async) + .with([Attr.of("service", "payments")]); +``` + +Dynamic reload uses the same stable-wrapper idea: + +```ecstasy +service ReloadableHandler(Handler initial) + implements Handler { + private Handler current = initial; + + void replace(Handler next) { + current = next; + } + + @Override + Boolean enabled(Level level) = current.enabled(level); + + @Override + void handle(Record record) { + current.handle(record); + } + + @Override + Handler withAttrs(Attr[] attrs) { + return new BoundHandler(this, attrs); + } + + @Override + Handler withGroup(String name) { + return new BoundHandler(this, name); + } +} +``` + +The important difference from Logback is category routing. slog does not have a +first-class logger-name hierarchy. You can still configure handlers by attrs: + +```json +{ + "rules": [ + {"match": {"component": "com.acme.payments"}, "level": "Debug"} + ] +} +``` + +but that is an application/backend convention, not a built-in Logback-compatible +longest-prefix logger tree. + +## Equivalence table + +| Logback idea | `lib_logging` equivalent | Go slog / `lib_slogging` equivalent | +|---|---|---| +| `` | `LogSink` implementation | `Handler` implementation | +| `` fanout | `CompositeLogSink` | Handler fanout wrapper; Go 1.26 adds `slog.MultiHandler`, and an Ecstasy `CompositeHandler` would be the same shape. | +| `` | `HierarchicalLogSink.setLevel(name, level)` | Attr-based handler rule, e.g. `component == "..."`; not built into slog. | +| `` | `rootLevel` in `ConsoleLogSink`, `JsonLogSinkOptions`, or `HierarchicalLogSink` | `HandlerOptions.rootLevel` | +| `AsyncAppender` | `AsyncLogSink` | `AsyncHandler` | +| JSON encoder | `JsonLogSink` | `JSONHandler` | +| `%X{requestId}` MDC rendering | `MDC` rendered by a sink | `Logger.with([Attr.of("requestId", id)])` or `LoggerContext` | +| `MarkerFilter` | marker-aware `LogSink` | attr filter such as `audit == true` | +| `scan="true"` reload | `ReloadableLogSink.replace(...)` in a config module | `ReloadableHandler.replace(...)` in a config module | + +## Recommendation + +For the canonical `lib_logging` path, use an Ecstasy JSON configuration file or typed +Ecstasy config object rather than Logback XML. Keep the model familiar: + +- named sinks/appenders; +- root level; +- per-logger overrides; +- async wrappers; +- JSON/text formatting options; +- redaction; +- reload by replacing a stable sink service's delegate. + +For the slog-shaped path, document that configuration is handler-graph construction. +It can be driven by the same JSON file style, but it should not pretend to have Logback +logger categories unless the handler explicitly implements attr-based routing. + +The user-facing rule stays the same in both designs: caller code depends on `Logger`; +configuration code owns sinks/handlers. + + +--- + +_See also [structured-logging.md](structured-logging.md), [custom-sinks.md](custom-sinks.md), +and [custom-handlers.md](custom-handlers.md)._ diff --git a/doc/logging/usage/slog-parity.md b/doc/logging/usage/slog-parity.md index 47bd82776a..f1c7ab7e28 100644 --- a/doc/logging/usage/slog-parity.md +++ b/doc/logging/usage/slog-parity.md @@ -22,6 +22,11 @@ Runtime `@Inject slogging.Logger logger;` is wired in this branch. The native in resolves resources by `(name, requested type)`, so both `logging.Logger logger` and `slogging.Logger logger` can use the familiar default resource name `logger`. +Important distinction: Go `slog` has `Logger.With(...)` and `Logger.WithGroup(...)`, +but it does **not** have SLF4J/Logback-style hierarchical logger names. `WithGroup` +qualifies attribute keys for output; it is not the same thing as a logger category such +as `com.acme.payments.stripe` with a Logback longest-prefix level rule. + ## `Logger` methods | Go `log/slog` | `lib_slogging` | @@ -78,6 +83,19 @@ logger is derived, instead of doing that work on every record. The shipped handl Logger logger = new Logger(new AsyncHandler(new JSONHandler())); ``` +## No markers or hierarchical names + +Two SLF4J/Logback concepts are intentionally not part of the slog API: + +| SLF4J / Logback concept | Go slog way | `lib_slogging` way | What you lose | +|---|---|---|---| +| `LoggerFactory.getLogger("com.acme.payments")` plus Logback prefix config | `slog.New(handler).With("component", "com.acme.payments")` | `logger.with([Attr.of("component", "com.acme.payments")])` | No built-in logger name field and no standard longest-prefix level tree. A handler can filter attrs, but that is custom policy. | +| `MarkerFactory.getMarker("AUDIT")` and marker filters | `logger.Info("...", "audit", true)` | `logger.info("...", [Attr.of("audit", True)])` | No marker identity, marker containment DAG, or marker-specific enabled check. | + +That trade-off is deliberate. The slog model keeps one structured channel (`Attr`) and +lets handlers decide how to render or filter it. The cost is that Java/Logback teams do +not get direct equivalents for marker filters or category-prefix configuration. + ## Context and source metadata The default Ecstasy style is explicit: diff --git a/doc/logging/usage/structured-logging.md b/doc/logging/usage/structured-logging.md index 4eb10d1eba..84bfd607cf 100644 --- a/doc/logging/usage/structured-logging.md +++ b/doc/logging/usage/structured-logging.md @@ -1,9 +1,130 @@ -# Structured logging in `lib_logging` +# Structured logging in the logging POC -This document explains how structured logging works in SLF4J 2.x, and how the same idea -maps onto `lib_logging`. "Structured" here means: events that carry typed key/value pairs -in addition to (or instead of) a free-form message, so that downstream consumers (log -aggregators, dashboards, alerting) can query on those fields without regexing the text. +This document explains structured logging as a requirement, then shows how the same +event looks in Java SLF4J/Logback, Go `log/slog`, Ecstasy `lib_logging`, and Ecstasy +`lib_slogging`. + +"Structured" means a log event carries typed key/value fields in addition to a +human-readable message. Downstream systems can query fields directly instead of +regexing text. + +## Why structured logging is mandatory now + +A modern logging framework is not just a better `print`. It feeds systems such as +Cloud Logging, CloudWatch, Azure Monitor, OpenSearch, Splunk, Grafana Loki, +OpenTelemetry collectors, alert rules, audit pipelines, and incident dashboards. Those +systems need stable fields. + +Unstructured text is still useful for a human reading a terminal: + +```text +payment processed p_123 amount=4200 currency=EUR merchant=m_42 +``` + +It is weak as production telemetry. Every consumer has to rediscover the schema by +parsing the message. A harmless wording change breaks dashboards and alerts. + +A structured event keeps the human message stable and carries machine-readable fields: + +```json +{ + "time": "2026-04-29T11:23:45.012Z", + "level": "INFO", + "logger": "com.example.PaymentService", + "message": "payment processed", + "paymentId": "p_123", + "amount": 4200, + "currency": "EUR", + "merchantId": "m_42", + "mdc": {"requestId": "r_abc"} +} +``` + +That is queryable: `paymentId="p_123"`, `amount > 1000`, `merchantId="m_42"`, +`requestId="r_abc"`, `level >= ERROR`. Any XDK logging design that cannot carry this +without parsing message text is not production-ready. + +## Same event in the four APIs + +### Java + SLF4J 2.x + +```java +log.atInfo() + .setMessage("payment processed") + .addKeyValue("paymentId", payment.id()) + .addKeyValue("amount", payment.amount()) + .addKeyValue("currency", payment.currency()) + .addKeyValue("merchantId", merchant.id()) + .log(); +``` + +This requires a Logback encoder/appender that reads SLF4J 2.x key/value pairs, for +example a JSON/logstash/cloud encoder. A plain PatternLayout usually renders only the +message unless configured to include key/value data. + +### Go + `log/slog` + +```go +logger.Info("payment processed", + "paymentId", payment.ID, + "amount", payment.Amount, + "currency", payment.Currency, + "merchantId", merchant.ID) +``` + +Go's built-in `JSONHandler` renders attrs as JSON fields. `With(...)` carries +request-wide fields: + +```go +requestLog := logger.With("requestId", req.ID) +requestLog.Info("payment processed", "paymentId", payment.ID) +``` + +### Ecstasy + `lib_logging` + +```ecstasy +logger.atInfo() + .setMessage("payment processed") + .addKeyValue("paymentId", payment.id) + .addKeyValue("amount", payment.amount) + .addKeyValue("currency", payment.currency) + .addKeyValue("merchantId", merchant.id) + .log(); +``` + +Context that applies to many events lives in MDC: + +```ecstasy +@Inject MDC mdc; +mdc.put("requestId", request.id); + +logger.atInfo() + .addKeyValue("paymentId", payment.id) + .log("payment processed"); +``` + +`JsonLogSink` renders MDC and key/value pairs as structured fields. + +### Ecstasy + `lib_slogging` + +```ecstasy +logger.info("payment processed", [ + Attr.of("paymentId", payment.id), + Attr.of("amount", payment.amount), + Attr.of("currency", payment.currency), + Attr.of("merchantId", merchant.id), +]); +``` + +Request-wide fields are attached by deriving a logger: + +```ecstasy +Logger requestLog = logger.with([Attr.of("requestId", request.id)]); +requestLog.info("payment processed", [Attr.of("paymentId", payment.id)]); +``` + +`JSONHandler` renders attrs as JSON fields and preserves nested `Attr.group(...)` +values. ## How SLF4J 2.x does structured logging @@ -102,16 +223,18 @@ The `LogEvent` const carries a `keyValues` field analogous to SLF4J's ```ecstasy const LogEvent( - String loggerName, - Level level, - String message, - Marker[] markers = [], - Exception? exception = Null, - Object[] arguments = [], - Map keyValues = [], - Map mdcSnapshot = [], - String threadName = "", - Time timestamp, + String loggerName, + Level level, + String message, + Time timestamp, + Marker[] markers = [], + Exception? exception = Null, + Object[] arguments = [], + Map mdcSnapshot = [], + String threadName = "", + String? sourceFile = Null, + Int sourceLine = -1, + Map keyValues = [], ); ``` @@ -150,6 +273,78 @@ LogSink sink = new JsonLogSink(new JsonLogSinkOptions( `JsonLogSink` renders through `lib_json`, preserves MDC/markers/key-values/source, and redacts configured keys before output. +## How `lib_slogging` maps onto this + +The slog-shaped library collapses message arguments, markers, and per-event key/value +pairs into one concept: `Attr`. + +### Layer 1 — call site + +```ecstasy +logger.info("payment processed", [ + Attr.of("paymentId", payment.id), + Attr.of("amount", payment.amount), + Attr.of("currency", payment.currency), + Attr.of("merchantId", merchant.id), +]); +``` + +For request-scoped fields, derive a logger once: + +```ecstasy +Logger requestLog = logger.with([ + Attr.of("requestId", request.id), + Attr.of("tenant", request.tenant), +]); +``` + +Groups are explicit nested attrs: + +```ecstasy +requestLog.info("payment processed", [ + Attr.group("payment", [ + Attr.of("id", payment.id), + Attr.of("amount", payment.amount), + Attr.of("currency", payment.currency), + ]), +]); +``` + +### Layer 2 — event data model + +`Record` carries the completed message and `Attr[]`: + +```ecstasy +const Record( + Time time, + Level level, + String message, + Attr[] attrs = [], + Exception? exception = Null, + String? sourceFile = Null, + Int sourceLine = -1, + String threadName = "", + ); +``` + +There is no separate marker channel and no separate message-argument channel. That is +the point of the slog design. + +### Layer 3 — handler + +`JSONHandler` renders attrs directly as JSON fields, preserving groups as nested JSON +objects and applying `HandlerOptions` redaction: + +```ecstasy +Handler handler = new JSONHandler(new HandlerOptions( + Level.Info, + ["authorization", "password"])); +Logger logger = new Logger(handler); +``` + +This is conceptually closer to Go's built-in `slog.NewJSONHandler` than to SLF4J's +facade-plus-encoder split. + ## What about `MDC` vs key/value pairs? SLF4J has _two_ structured-data channels: @@ -160,8 +355,13 @@ SLF4J has _two_ structured-data channels: *this* specific log entry: payment ID, amount, target. `lib_logging` keeps the same split. `MDC` is its own service; `LoggingEventBuilder.addKeyValue` -is per-event. A structured sink should render both — typically MDC under an `"mdc"` sub-object -and KV pairs at the top level, matching what `LogstashEncoder` does. +is per-event. A structured sink should render both — typically MDC under an `"mdc"` +sub-object and KV pairs at the top level, matching what `LogstashEncoder` does. + +`lib_slogging` keeps the slog shape: request-scoped data is normally attached by +deriving a logger with `with(attrs)`, and per-event data is passed as attrs on the +specific log call. `LoggerContext` is available only when framework code needs +implicit propagation. ## Migration story for SLF4J users adopting `lib_logging` @@ -173,6 +373,22 @@ The bigger story — encoder/appender wiring, log aggregator integration — is the structured sink (`JsonLogSink` in the simple case, a future configured backend in the complex case). See `../future/logback-integration.md`. +## Migration story for Go slog users adopting `lib_slogging` + +The conceptual migration is also direct: + +| Go slog | Ecstasy `lib_slogging` | +|---|---| +| `logger.Info("payment processed", "paymentId", id)` | `logger.info("payment processed", [Attr.of("paymentId", id)])` | +| `logger.With("requestId", id)` | `logger.with([Attr.of("requestId", id)])` | +| `logger.WithGroup("payment")` | `logger.withGroup("payment")` | +| `slog.Group("payment", "id", id)` | `Attr.group("payment", [Attr.of("id", id)])` | +| `slog.NewJSONHandler(w, opts)` | `new JSONHandler(new HandlerOptions(...))` | + +The main Ecstasy difference is that attrs are explicit `Attr` values, not alternating +`key, value` arguments. That avoids Go's malformed-argument case and keeps the record +typed before it reaches the handler. + ## What to build first vs. later For the next production-oriented cut, the right order is: diff --git a/lib_logging/README.md b/lib_logging/README.md index 42e4b2711c..d5f2d7a750 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -8,6 +8,9 @@ Experimental SLF4J-shaped logging library for Ecstasy. > composite, hierarchical-level, and JSON/redaction sinks. JIT-side injection and > full configuration-file loading remain future work; see [`doc/logging/`](../doc/logging). +> **POC naming note:** `lib_slogging` exists beside this module only for comparison. +> The XDK should settle on one injectable logging design and ship it as `lib_logging`. + ## What this is A logging library for Ecstasy with the SLF4J 2.x shape: named loggers, levels, diff --git a/lib_slogging/README.md b/lib_slogging/README.md index 4436245af4..e49caa3f54 100644 --- a/lib_slogging/README.md +++ b/lib_slogging/README.md @@ -8,6 +8,11 @@ Experimental Go `log/slog`-shaped structured logging library for Ecstasy. > wrapping, handler options/redaction, and unit tests are in this branch. > `JSONHandler` renders through `lib_json`. +> **POC naming note:** this module exists beside `lib_logging` only so the branch can +> compare two API shapes. The XDK should eventually ship one canonical injectable +> logging library named `lib_logging`; `lib_slogging` is not intended as a permanent +> second default facade. + ## What this is `lib_slogging` is the attribute-first alternative to the SLF4J-shaped From 1d2702c9dd83f64c5a578943387237123efcebbe Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 09:54:27 +0200 Subject: [PATCH 14/28] Expand logging configuration guide --- doc/logging/README.md | 20 +- doc/logging/usage/configuration.md | 579 ++++++++++++++++++++++++++++- 2 files changed, 581 insertions(+), 18 deletions(-) diff --git a/doc/logging/README.md b/doc/logging/README.md index 9ed0db393e..3bde930841 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -12,25 +12,25 @@ POC should either disappear or become an explicitly named adapter/comparison mod ## Requirements -The accepted XDK logging API should satisfy these requirements: +The accepted XDK logging API **MUST** satisfy these requirements: -- **One canonical facade:** one injectable logging API, published as `lib_logging`. -- **Familiar call shape:** instantly recognizable to either SLF4J/Logback or slog users, +- **One canonical facade:** there MUST be one injectable logging API, published as `lib_logging`. +- **Familiar call shape:** the call shape MUST be instantly recognizable to either SLF4J/Logback or slog users, with migration examples for Java/Go teams. -- **Injection-first acquisition:** `@Inject Logger logger;` lets the host/container own +- **Injection-first acquisition:** `@Inject Logger logger;` MUST let the host/container own backend policy; library code must not choose global process logging. -- **Structured events:** first-class key/value fields, context fields, exceptions, +- **Structured events:** events MUST carry first-class key/value fields, context fields, exceptions, markers/categories where applicable, and JSON/cloud output without parsing messages. -- **Dynamically pluggable backends:** sinks/handlers can be swapped, composed, wrapped +- **Dynamically pluggable backends:** sinks/handlers MUST be swappable, composable, wrappable with async queues, and replaced by a host-controlled reload service. -- **Logback-equivalent operations:** root level, per-logger/category overrides, +- **Logback-equivalent operations:** the backend MUST support root level, per-logger/category overrides, multi-destination fanout, async output, JSON/text formatting, redaction, and configuration-file reload. -- **Production safety:** backend failures should not break callers; redaction, +- **Production safety:** backend failures MUST NOT break callers; redaction, output destinations, formatting knobs, and shutdown/flush behavior must be explicit. -- **Testability:** in-memory capture sinks/handlers and contract tests for custom +- **Testability:** the API MUST provide in-memory capture sinks/handlers and contract tests for custom backends. -- **Runtime/compiler path:** default logger naming and source-location capture should +- **Runtime/compiler path:** default logger naming and source-location capture MUST have a clear lowering target even if full compiler sugar lands later. Both libraries use the same XDK-facing architecture: application code receives an diff --git a/doc/logging/usage/configuration.md b/doc/logging/usage/configuration.md index 0107d4be63..f5ae52eeaf 100644 --- a/doc/logging/usage/configuration.md +++ b/doc/logging/usage/configuration.md @@ -8,6 +8,29 @@ debug-enabled. This is exactly the SLF4J/Logback split: SLF4J is the facade, Logback is the configured backend. The Ecstasy shape should preserve that split. +## Configuration prior art to account for + +Logback is the best-known SLF4J backend, but it is not the only operational model that +reviewers will recognize. The XDK design should be understandable to teams coming from +these systems too: + +| System | Why it matters | What the XDK design should preserve | +|---|---|---| +| [Logback](https://logback.qos.ch/manual/configuration.html) | The default backend in many SLF4J deployments and the mental model most Java teams mean by "configured logging". | Named loggers, root level, appenders, encoders, async appenders, reload, MDC, markers. | +| [Apache Log4j 2](https://logging.apache.org/log4j/2.x/manual/configuration.html) | The other major JVM logging framework. It has XML, JSON, YAML, and properties configuration, appenders, layouts, filters, async logging, and custom plugins. | The same backend graph should be expressible without requiring Logback XML: loggers, appenders/sinks, layouts/formatters, filters, async wrappers, and JSON output. | +| [Spring Boot logging](https://docs.spring.io/spring-boot/reference/features/logging.html) | Not a logging backend, but a very common deployment experience. Operators set `logging.level.*`, `logging.file.*`, and `logging.config` through properties, env vars, or command-line args. | The XDK should have simple override keys for the common cases and a structured config file for the full backend graph. | +| [java.util.logging](https://docs.oracle.com/en/java/javase/26/docs/api/java.logging/java/util/logging/LogManager.html) | The JDK built-in logger. Many libraries still emit through it, and bridges are common. | Hierarchical names and handler/formatter concepts are familiar, but the XDK should avoid JUL's global `LogManager` and flat properties-only limitations. | +| [Commons Logging](https://commons.apache.org/logging/index.html), JBoss Logging, and bridges | Facades exist because libraries should not force an application backend. | `@Inject Logger` should give Ecstasy the same library/application separation without classpath discovery. | +| [logstash-logback-encoder](https://github.com/logfellow/logstash-logback-encoder) and cloud appenders | This is how many SLF4J/Logback services produce structured JSON for Elastic, Cloud Logging, CloudWatch, or Azure Monitor. | JSON and cloud output must be first-class backend concerns, not message parsing hacks. | + +For Go, the comparison point is different. [`log/slog`](https://pkg.go.dev/log/slog) +does not define a `logback.xml`-style configuration file. A Go program usually parses +its own flags, environment variables, or config file, constructs a handler graph in +`main`, and installs it with `slog.SetDefault`. Dynamic levels are represented by +`slog.LevelVar`; redaction and field rewriting are represented by +`HandlerOptions.ReplaceAttr`; fanout is a handler wrapper such as `slog.MultiHandler` +or a third-party/custom handler. + ## Java Logback baseline A typical Logback config combines: @@ -103,18 +126,178 @@ better fit for the XDK because the backend graph is already typed and programmat This is the same information as the Logback XML, but the names line up with XDK objects: sinks, levels, destinations, redaction, field names, and logger-name routing. +## Recommended XDK configuration inputs + +The POC docs use JSON for the full backend graph. XML is familiar from Logback, but JSON +fits the XDK better because it maps directly to typed Ecstasy config objects and to the +same JSON tooling used for service configuration. A production container should accept +these inputs in a predictable order: + +1. Built-in defaults: console output, root `Info`, no cloud sink. +2. Packaged `logging.json` or `slogging.json` next to the application. +3. External file from `--logging.config=...`, `--slogging.config=...`, + `XTC_LOGGING_CONFIG`, or `XTC_SLOGGING_CONFIG`. +4. Environment/property overrides for deployment-specific values. +5. Command-line overrides for the last-mile operator change. + +For the eventual single XDK API, only the `logging.*` namespace should remain. While +this branch contains both POC libraries, the examples use `logging.*` for +`lib_logging` and `slogging.*` for `lib_slogging` so reviewers can compare them without +name collisions. + +Common property overrides should look like this: + +```properties +logging.config=/etc/acme/logging.json +logging.rootLevel=Info +logging.logger.com.acme.payments.level=Debug +logging.logger.com.acme.audit.level=Info +logging.sink.file-json.path=/var/log/acme/payments.jsonl +logging.sink.cloud.provider=gcp +logging.sink.cloud.project=acme-prod +logging.sink.cloud.service=payments +logging.sink.async-cloud.capacity=8192 +logging.redactedKeys=authorization,password,token +``` + +The same settings should be accepted as environment variables in deployment systems that +prefer env configuration: + +```bash +XTC_LOGGING_CONFIG=/etc/acme/logging.json +XTC_LOGGING_ROOT_LEVEL=Info +XTC_LOGGING_LOGGER_COM_ACME_PAYMENTS_LEVEL=Debug +XTC_LOGGING_SINK_CLOUD_PROVIDER=gcp +XTC_LOGGING_SINK_CLOUD_PROJECT=acme-prod +XTC_LOGGING_SINK_CLOUD_SERVICE=payments +XTC_LOGGING_REDACTED_KEYS=authorization,password,token +``` + +Command-line overrides should be reserved for operational changes: + +```bash +xtc run app.xtc \ + --logging.config=/etc/acme/logging.json \ + --logging.level.root=Warn \ + --logging.level.com.acme.payments=Debug \ + --logging.sink.cloud.provider=gcp \ + --logging.sink.cloud.project=acme-prod +``` + +The slog-shaped POC uses the same deployment idea with handler vocabulary: + +```properties +slogging.config=/etc/acme/slogging.json +slogging.rootLevel=Info +slogging.rule.component.com.acme.payments.level=Debug +slogging.handler.file-json.path=/var/log/acme/payments.jsonl +slogging.handler.cloud.provider=aws +slogging.handler.cloud.logGroup=/acme/payments +slogging.handler.async-cloud.capacity=8192 +slogging.attr.service=payments +``` + +```bash +xtc run app.xtc \ + --slogging.config=/etc/acme/slogging.json \ + --slogging.level.root=Info \ + --slogging.rule.component.com.acme.payments=Debug \ + --slogging.handler.cloud.provider=aws +``` + +The rule is: JSON describes the graph; properties, env vars, and command-line args patch +the graph. They should not become a second full configuration language. + +## What is implemented vs proposed + +The examples below deliberately mix shipped POC types with the next layer that would +make logging a production XDK subsystem. The boundary should be explicit: + +| Layer | Status in this branch | Recommended XDK home | +|---|---|---| +| Caller facade: `Logger`, levels, message formatting/attrs, MDC/context, markers where applicable | Implemented in `lib_logging` and `lib_slogging`. | The winning API becomes the first-class `lib_logging` module. The losing POC should not remain a peer facade. | +| Backend SPI: `LogSink` for `lib_logging`, `Handler` for `lib_slogging` | Implemented. | Stays in core `lib_logging`, because custom backends need the SPI without importing a config system. | +| Base backend primitives with no deployment dependencies | Implemented: `ConsoleLogSink`, `JsonLogSink`, `CompositeLogSink`, `HierarchicalLogSink`, `AsyncLogSink`, `MemoryLogSink`; slog equivalents include `TextHandler`, `JSONHandler`, `AsyncHandler`, `MemoryHandler`, `BoundHandler`. | Stays in core `lib_logging` if the dependency footprint remains small. | +| File destinations, rolling files, cloud destinations, provider clients | Not implemented here. The examples use names such as `FileJsonLogSink`, `CloudLogSink`, and `CloudHandler` as sketches. | Optional first-class XDK backend libraries, for example `lib_logging_file` and `lib_logging_cloud`, or provider-specific modules such as `lib_logging_gcp`, `lib_logging_aws`, and `lib_logging_azure` if dependencies are heavy. | +| JSON config schema, config parser, env/property/CLI override merge, file watching, hot reload services | Not implemented here. The examples use `LoggingConfig`, `SLoggingConfig`, `ConfiguredLogSink`, and `ConfiguredHandler` as sketches. | A first-class XDK config library, for example `lib_logging_config`, importing `logging`, `json`, and whichever backend modules it can instantiate. | +| Runtime defaults: automatic logger naming and automatic source-location capture | Partially sketched. The native injection POC exists; automatic naming/source capture remain compiler/runtime work. | Runtime/compiler, not the logging library itself. The library already has explicit fields/methods for the lowered values. | + +If the slog-shaped API wins, the module split should be the same after renaming the +chosen facade to `lib_logging`: core logger/handler types in `lib_logging`, config +loading in `lib_logging_config`, and optional file/cloud handlers in backend libraries. + +## Log4j 2 and Spring Boot comparison + +Log4j 2 proves that XML is not required for a serious backend. The same appender/logger +graph can be written in JSON: + +```json +{ + "Configuration": { + "Appenders": { + "Console": { + "name": "STDOUT", + "PatternLayout": {"pattern": "%d [%t] %p %c - %m%n"} + }, + "File": { + "name": "JSON", + "fileName": "logs/app.jsonl", + "JsonTemplateLayout": {} + } + }, + "Loggers": { + "Logger": { + "name": "com.acme.payments", + "level": "debug", + "AppenderRef": [{"ref": "JSON"}, {"ref": "STDOUT"}] + }, + "Root": { + "level": "info", + "AppenderRef": {"ref": "STDOUT"} + } + } + } +} +``` + +Spring Boot proves that operators also need simple overrides for common cases: + +```properties +logging.level.root=warn +logging.level.com.acme.payments=debug +logging.file.name=/var/log/acme/app.log +logging.config=classpath:logback-spring.xml +``` + +The Ecstasy proposal should copy that operational convenience without copying Spring or +Logback internals. A minimal XDK equivalent is: + +```properties +logging.level.root=Warn +logging.level.com.acme.payments=Debug +logging.sink.file-json.path=/var/log/acme/app.jsonl +logging.config=/etc/acme/logging.json +``` + +That is enough for "turn on debug for this component now" while the JSON file remains +the source of truth for multi-sink, cloud, redaction, and formatter decisions. + ## How this maps to `lib_logging` The current POC already has the backend building blocks: -| Config concept | POC type | -|---|---| -| Console appender | `ConsoleLogSink` | -| JSON encoder / JSON appender | `JsonLogSink` + `JsonLogSinkOptions` | -| Async appender | `AsyncLogSink` | -| Multiple appenders | `CompositeLogSink` | -| Per-logger level tree | `HierarchicalLogSink` | -| Test/list appender | `MemoryLogSink` | +| Config concept | Status | Type / future home | +|---|---|---| +| Console appender | Implemented | `ConsoleLogSink` in `lib_logging` | +| JSON encoder / JSON appender | Implemented for console-style JSON lines | `JsonLogSink` + `JsonLogSinkOptions` in `lib_logging` | +| Async appender | Implemented | `AsyncLogSink` in `lib_logging` | +| Multiple appenders | Implemented | `CompositeLogSink` in `lib_logging` | +| Per-logger level tree | Implemented | `HierarchicalLogSink` in `lib_logging` | +| Test/list appender | Implemented | `MemoryLogSink` in `lib_logging` | +| File output / rolling file output | Proposed | `FileJsonLogSink` or `RollingFileLogSink` in `lib_logging_file` | +| Cloud output | Proposed | `CloudLogSink` plus provider clients in `lib_logging_cloud` or provider modules | +| Config JSON loader and override merge | Proposed | `LoggingConfig` and loader services in `lib_logging_config` | +| Dynamic reload wrapper | Proposed | `ConfiguredLogSink` / `ReloadableLogSink` in `lib_logging_config` | A config loader can build the sink graph from JSON: @@ -174,6 +357,180 @@ configured.setLevel("com.acme.audit", Info); Those methods belong in a future configuration module, not in the caller-facing `Logger` API. +## Self-contained `lib_logging` dynamic backend sketch + +This example shows the production shape reviewers should expect from the +SLF4J/Logback-shaped path: + +- root level is `Info`; +- `com.acme.payments` is set to `Debug`; +- payment logs tee to a local file and a cloud logging sink; +- audit logs can be added dynamically without changing caller code; +- the active backend can be rebuilt from `logging.json` and swapped at runtime. + +Everything in this sketch that implements `LogSink` uses the already-shipped extension +point. `ConsoleLogSink`, `JsonLogSink`, `CompositeLogSink`, `HierarchicalLogSink`, and +`AsyncLogSink` exist today. `FileJsonLogSink`, `CloudLogSink`, `CloudLogClient`, +`LoggingConfig`, and `ConfiguredLogSink` are proposed first-class backend/config types; +they should not be added to application code. + +```json +{ + "version": 1, + "logging": { + "rootLevel": "Info", + "sinks": [ + {"name": "console", "type": "console"}, + { + "name": "file-json", + "type": "file", + "path": "logs/payments.jsonl", + "format": "json", + "redactedKeys": ["authorization", "password", "token"] + }, + { + "name": "cloud", + "type": "cloud", + "provider": "gcp", + "project": "acme-prod", + "service": "payments", + "resource": {"type": "k8s_container"}, + "traceKeys": {"trace": "traceId", "span": "spanId"} + }, + { + "name": "async-cloud", + "type": "async", + "capacity": 8192, + "delegate": "cloud" + }, + { + "name": "payments-tee", + "type": "composite", + "sinks": ["file-json", "async-cloud", "console"] + } + ], + "loggers": [ + {"name": "com.acme.payments", "level": "Debug", "sinks": ["payments-tee"]}, + {"name": "com.acme.audit", "level": "Info", "sinks": ["async-cloud"]} + ] + } +} +``` + +The proposed cloud sink API shape is deliberately small: + +```ecstasy +interface CloudLogClient { + void write(CloudLogEntry entry); +} + +const CloudLogEntry( + String provider, + String severity, + String message, + Map payload, + Map labels = [], + String? trace = Null, + String? spanId = Null, + ); + +const CloudLogSink(CloudLogClient client, String provider, String service) + implements LogSink { + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return True; + } + + @Override + void log(LogEvent event) { + Map payload = new ListMap(); + payload.put("logger", event.loggerName); + payload.put("message", event.message); + payload.putAll(event.keyValues); + payload.put("mdc", event.mdcSnapshot); + + Map labels = new ListMap(); + for (Marker marker : event.markers) { + labels.put(marker.name, "true"); + } + + client.write(new CloudLogEntry( + provider, + event.level.name, + event.message, + payload, + labels, + trace = event.mdcSnapshot.getOrNull("traceId"), + spanId = event.mdcSnapshot.getOrNull("spanId"))); + } +} +``` + +A future `lib_logging_config` module would build and own the dynamic graph: + +```ecstasy +service ConfiguredLogSink(LogSink initial) + implements LogSink { + private LogSink current = initial; + + void reload(LoggingConfig config, CloudLogClient cloud) { + LogSink fileJson = new FileJsonLogSink(config.file("file-json")); + LogSink cloudSink = new CloudLogSink(cloud, config.provider, config.service); + LogSink asyncCloud = new AsyncLogSink(cloudSink, config.capacity("async-cloud")); + LogSink tee = new CompositeLogSink([fileJson, asyncCloud, new ConsoleLogSink()]); + + HierarchicalLogSink next = new HierarchicalLogSink(tee, config.rootLevel); + next.setLevel("com.acme.payments", Debug); + next.setLevel("com.acme.audit", Info); + + current = next; + } + + void addAuditCloudSink(CloudLogClient auditCloud) { + LogSink audit = new AsyncLogSink(new CloudLogSink(auditCloud, "gcp", "audit")); + // A production implementation would rebuild a named sink graph here; this sketch + // shows the operation belongs to the backend service, not caller code. + current = new CompositeLogSink([current, audit]); + } + + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { + return current.isEnabled(loggerName, level, marker); + } + + @Override + void log(LogEvent event) { + current.log(event); + } +} +``` + +The cloud client is provider-specific, but the sink boundary is not: + +- **GCP Cloud Logging:** map `Level` to `severity`, `message` to the displayed message, + MDC/key-values to JSON payload, markers to labels, and trace/span IDs from MDC. +- **AWS CloudWatch Logs:** emit one JSON event to a log group/log stream or stdout for + the platform collector; keep level, logger, request ID, and trace ID as indexed fields. +- **Azure Monitor / Application Insights:** map level to severity, message to trace text, + MDC/key-values/markers to custom properties, and trace/span IDs to operation context. + +The caller sees none of this: + +```ecstasy +@Inject Logger logger; +@Inject MDC mdc; + +mdc.put("requestId", request.id); +mdc.put("traceId", request.traceId); + +logger.named("com.acme.payments.stripe") + .atInfo() + .addKeyValue("paymentId", payment.id) + .addKeyValue("amount", payment.amount) + .log("payment processed"); +``` + ## How this maps to Go slog / `lib_slogging` Go `log/slog` does not define a standard configuration-file format comparable to @@ -182,6 +539,22 @@ or environment variables. The standard API provides `Handler`, `HandlerOptions`, `TextHandler`, `JSONHandler`, `WithAttrs`, and `WithGroup`; configuration is ordinary Go code around those pieces. +The POC already has the slog-shaped core pieces: + +| Config concept | Status | Type / future home | +|---|---|---| +| Logger facade and attrs | Implemented | `slogging.Logger`, `Attr`, `Record`, `Level` in `lib_slogging` | +| Handler SPI | Implemented | `Handler` and `BoundHandler` in `lib_slogging` | +| Text and JSON output | Implemented | `TextHandler`, `JSONHandler`, `HandlerOptions` in `lib_slogging` | +| Async wrapper | Implemented | `AsyncHandler` in `lib_slogging` | +| Test/discard handlers | Implemented | `MemoryHandler`, `NopHandler` in `lib_slogging` | +| Request logger context | Implemented | `LoggerContext` in `lib_slogging` | +| Handler fanout | Proposed | `CompositeHandler` in core `lib_logging` if slog wins, or in `lib_logging_config` if kept config-only | +| File output / rolling file output | Proposed | `FileJSONHandler` or `RollingFileHandler` in `lib_logging_file` | +| Cloud output | Proposed | `CloudHandler` plus provider clients in `lib_logging_cloud` or provider modules | +| Attr-based dynamic level routing | Proposed | `AttrLevelRoutingHandler` in `lib_logging_config` if slog wins | +| Config JSON loader and override merge | Proposed | `SLoggingConfig`-equivalent config objects in `lib_logging_config` if slog wins | + The equivalent Ecstasy JSON for the slog-shaped POC would configure handlers instead of sinks: @@ -272,6 +645,196 @@ first-class logger-name hierarchy. You can still configure handlers by attrs: but that is an application/backend convention, not a built-in Logback-compatible longest-prefix logger tree. +## Self-contained `lib_slogging` dynamic handler sketch + +The slog-shaped version is the same backend idea with different vocabulary: + +- configure a handler graph instead of a sink graph; +- use attrs for service/component/request context; +- tee records to file and cloud through a composite handler; +- apply level rules by attr matching, not logger-name prefix matching. + +As above, the core handler API is implemented. `JSONHandler`, `TextHandler`, +`AsyncHandler`, `HandlerOptions`, `Attr`, and `Logger.with(...)` exist today. +`CompositeHandler`, `FileJSONHandler`, `CloudHandler`, `AttrLevelRoutingHandler`, and +the JSON/property/CLI config loader are proposed additions. If the slog shape wins, +they should graduate under `lib_logging` / `lib_logging_config`, not remain under a +permanent `lib_slogging` name. + +```json +{ + "version": 1, + "slogging": { + "rootLevel": "Info", + "handlers": [ + { + "name": "file-json", + "type": "file", + "path": "logs/payments.jsonl", + "format": "json", + "redactedKeys": ["authorization", "password", "token"] + }, + { + "name": "cloud", + "type": "cloud", + "provider": "aws", + "logGroup": "/acme/payments", + "service": "payments", + "traceKeys": {"trace": "traceId", "span": "spanId"} + }, + { + "name": "async-cloud", + "type": "async", + "capacity": 8192, + "delegate": "cloud" + }, + { + "name": "tee", + "type": "composite", + "handlers": ["file-json", "async-cloud"] + } + ], + "rules": [ + {"match": {"component": "com.acme.payments"}, "level": "Debug"}, + {"match": {"audit": true}, "level": "Info"} + ], + "context": { + "attrs": [ + {"key": "service", "value": "payments"} + ] + } + } +} +``` + +The proposed handler additions: + +```ecstasy +const CompositeHandler(Handler[] handlers) + implements Handler { + + @Override + Boolean enabled(Level level) { + for (Handler handler : handlers) { + if (handler.enabled(level)) { + return True; + } + } + return False; + } + + @Override + void handle(Record record) { + for (Handler handler : handlers) { + if (handler.enabled(record.level)) { + handler.handle(record); + } + } + } + + @Override + Handler withAttrs(Attr[] attrs) { + return new CompositeHandler(handlers.map(h -> h.withAttrs(attrs))); + } + + @Override + Handler withGroup(String name) { + return new CompositeHandler(handlers.map(h -> h.withGroup(name))); + } +} + +const CloudHandler(CloudLogClient client, String provider, String service) + implements Handler { + + @Override + Boolean enabled(Level level) = True; + + @Override + void handle(Record record) { + Map payload = new ListMap(); + payload.put("message", record.message); + for (Attr attr : record.attrs) { + payload.put(attr.key, attr.value); + } + + client.write(new CloudLogEntry( + provider, + record.level.label, + record.message, + payload, + labels = ["service" = service], + trace = payload.getOrNull("traceId")?.toString(), + spanId = payload.getOrNull("spanId")?.toString())); + } + + @Override + Handler withAttrs(Attr[] attrs) = new BoundHandler(this, attrs); + + @Override + Handler withGroup(String name) = new BoundHandler(this, name); +} +``` + +Dynamic reload again uses a stable handler: + +```ecstasy +service ConfiguredHandler(Handler initial) + implements Handler { + private Handler current = initial; + + void reload(SLoggingConfig config, CloudLogClient cloud) { + Handler fileJson = new FileJSONHandler(config.file("file-json")); + Handler cloudHandler = new AsyncHandler( + new CloudHandler(cloud, config.provider, config.service), + config.capacity("async-cloud")); + + Handler tee = new CompositeHandler([fileJson, cloudHandler]); + current = new AttrLevelRoutingHandler(tee, config.rules, config.rootLevel) + .withAttrs([Attr.of("service", config.service)]); + } + + @Override + Boolean enabled(Level level) = current.enabled(level); + + @Override + void handle(Record record) { + current.handle(record); + } + + @Override + Handler withAttrs(Attr[] attrs) { + return new BoundHandler(this, attrs); + } + + @Override + Handler withGroup(String name) { + return new BoundHandler(this, name); + } +} +``` + +Caller code is still simple: + +```ecstasy +@Inject slogging.Logger logger; + +slogging.Logger payments = logger.with([ + Attr.of("component", "com.acme.payments"), + Attr.of("requestId", request.id), + Attr.of("traceId", request.traceId), +]); + +payments.info("payment processed", [ + Attr.of("paymentId", payment.id), + Attr.of("amount", payment.amount), +]); +``` + +This is cloud-compatible for the same reason as the `lib_logging` version: every field +that cloud providers care about is present as structured data. The trade-off is routing: +the slog-shaped backend can route by attrs, but it does not provide Logback's +standardized hierarchical logger category model. + ## Equivalence table | Logback idea | `lib_logging` equivalent | Go slog / `lib_slogging` equivalent | From e0abf08908f5713f571dca7e1200a9cca8ea766d Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 10:24:01 +0200 Subject: [PATCH 15/28] Clarify logging documentation entry points --- doc/logging/README.md | 51 +++++++++++++++----- doc/logging/lib-logging-vs-lib-slogging.md | 50 ++++++++++++++----- doc/logging/open-questions.md | 2 +- doc/logging/usage/configuration.md | 26 ++++++++++ doc/logging/usage/injected-logger-example.md | 2 +- doc/logging/usage/structured-logging.md | 16 ++++++ lib_logging/README.md | 2 +- 7 files changed, 122 insertions(+), 27 deletions(-) diff --git a/doc/logging/README.md b/doc/logging/README.md index 3bde930841..b622df600d 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -10,6 +10,33 @@ one logging shape or the other, not both in the same design. Once the XDK settle injectable logging model, the winning API should be the XDK's `lib_logging`; the losing POC should either disappear or become an explicitly named adapter/comparison module. +## First-pass summary + +Read this file first. It gives the whole design without requiring any deep dive: + +1. Application code asks for a logger with `@Inject Logger logger;`. +2. The injected logger is a small facade. Caller code logs messages, exceptions, + structured fields, markers/categories, and request context. +3. The host/container owns the backend. It can replace the active `LogSink` or `Handler` + with console, JSON, async, fanout, hierarchical-level, file, cloud, or test capture + implementations without changing application code. +4. `lib_logging` is the recommended canonical shape because it gives Ecstasy the + SLF4J/Logback model most JVM users expect: named loggers, `{}` formatting, markers, + MDC, fluent event builders, and a narrow `LogSink` backend SPI. +5. `lib_slogging` exists to compare the Go `log/slog` model: one structured carrier + (`Attr`), derived loggers via `with(...)`, open integer levels, grouped attrs, and a + `Handler` backend SPI. +6. The POC already implements both facades, injection, JSON output, redaction knobs, + async wrappers, memory test capture, and backend extension points. Production config + loading, rolling files, provider-specific cloud clients, automatic source capture, and + compiler-generated logger names are follow-up modules/runtime work. + +For a first review pass, read this README and then the top summaries of +[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md), +[`usage/structured-logging.md`](usage/structured-logging.md), and +[`usage/configuration.md`](usage/configuration.md). Those documents keep their long +rationale and implementation details below the fast path. + ## Requirements The accepted XDK logging API **MUST** satisfy these requirements: @@ -18,7 +45,7 @@ The accepted XDK logging API **MUST** satisfy these requirements: - **Familiar call shape:** the call shape MUST be instantly recognizable to either SLF4J/Logback or slog users, with migration examples for Java/Go teams. - **Injection-first acquisition:** `@Inject Logger logger;` MUST let the host/container own - backend policy; library code must not choose global process logging. + backend policy; library code MUST NOT choose global process logging. - **Structured events:** events MUST carry first-class key/value fields, context fields, exceptions, markers/categories where applicable, and JSON/cloud output without parsing messages. - **Dynamically pluggable backends:** sinks/handlers MUST be swappable, composable, wrappable @@ -27,7 +54,7 @@ The accepted XDK logging API **MUST** satisfy these requirements: multi-destination fanout, async output, JSON/text formatting, redaction, and configuration-file reload. - **Production safety:** backend failures MUST NOT break callers; redaction, - output destinations, formatting knobs, and shutdown/flush behavior must be explicit. + output destinations, formatting knobs, and shutdown/flush behavior MUST be explicit. - **Testability:** the API MUST provide in-memory capture sinks/handlers and contract tests for custom backends. - **Runtime/compiler path:** default logger naming and source-location capture MUST @@ -43,11 +70,6 @@ to JSON/cloud sinks without parsing message text. | [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 64 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | | [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 37 focused XTC test methods, injected/manual coverage, async handler, handler options, and JSON/redaction support. | -If you only have time to read one document, read -**[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md)** — it is -the comparison, the reviewer-question list, and the recommendation in one -place. - ## Recommendation Choose **`lib_logging`** as the canonical XDK logging facade. If reviewers choose the @@ -62,8 +84,15 @@ these APIs were designed to avoid. ## Reading Paths -Start with [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) if -you only read one file. The rest of the tree is organized by reader: +Start here, then follow only the path that matches your review: + +1. **First-pass design:** this README. +2. **API choice:** [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md). +3. **Structured logging requirement:** [`usage/structured-logging.md`](usage/structured-logging.md). +4. **Configuration/backend story:** [`usage/configuration.md`](usage/configuration.md). +5. **Exact API mapping:** [`api-cross-reference.md`](api-cross-reference.md). + +The rest of the tree is organized by reader: | Reader | Start with | Then read | |---|---|---| @@ -81,10 +110,10 @@ you only read one file. The rest of the tree is organized by reader: |---|---| | **Top-level** | | | [`README.md`](README.md) | This file. The "start here" entry point. | -| [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | Headline comparison + reviewer questions. **Read first.** | +| [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | API-choice summary first, then depth-first rationale on levels, context, markers, formatting, and backend shape. | | [`api-cross-reference.md`](api-cross-reference.md) | Official SLF4J / Go slog API links mapped to local Ecstasy types and difference notes. | | [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | -| [`open-questions.md`](open-questions.md) | Live tracking: open questions, designer questions (Q-D1..Q-D7), W-item parity list, implementation tiers. | +| [`open-questions.md`](open-questions.md) | Decision tracker: resolved calls, designer questions (Q-D1..Q-D7), W-item parity list, and remaining follow-up. | | **Design (`lib_logging` side)** | | | [`design/design.md`](design/design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | | [`design/why-slf4j-and-injection.md`](design/why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index 1cce3e3182..0823e60a4c 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -34,6 +34,30 @@ Go `log/slog` counterpart are in [`api-cross-reference.md`](api-cross-reference. --- +## Fast conclusion + +Use this section for the first pass; the later sections are the depth-first rationale. + +Choose **`lib_logging`** as the canonical XDK facade unless review explicitly decides +Ecstasy should prefer the Go `slog` mental model. The deciding point is operational +familiarity: `lib_logging` gives users named loggers, `{}` formatting, markers, MDC, +fluent event builders, and a Logback-style `LogSink` boundary, while still supporting +modern structured JSON/cloud output through `LogEvent.keyValues`. + +`lib_slogging` proves the alternative is viable and clean. It is smaller because all +structured data is `Attr`, and backend extension is through `Handler`. The cost is that +hierarchical logger names, markers, and message templates are not first-class; they must +be modeled as attrs or helper adapters. + +The comparison can be read in two passes: + +- **Sections 1-3:** enough to understand the API choice and what exists or does not + exist in each model. +- **Sections 4-8:** deeper rationale, marker examples, Ecstasy `const`/`service` + implications, reviewer questions, and exact branch contents. + +--- + ## 1. Same scenario in both APIs A nontrivial example: an HTTP request handler logs request entry, attaches a @@ -246,7 +270,7 @@ async/composite/hierarchical backend primitives. ## 4. Per-axis analysis -### 3.1 Levels — closed enum vs open integer +### 4.1 Levels — closed enum vs open integer SLF4J's five levels are an industry consensus, easy to pattern-match on, and remove a class of "what's the right level for this?" questions because the choice is small. @@ -264,7 +288,7 @@ Ecstasy already treats severity in many places (raw `Int`-shaped severity in `Comparable`/`Orderable` machinery. The SLF4J model is a `const` enum with explicit methods — also clean, but rigidly closed. -### 3.2 Context propagation — `MDC` vs `With(attrs)` +### 4.2 Context propagation — `MDC` vs `With(attrs)` This is the single biggest design fork. @@ -294,7 +318,7 @@ small: explicit `Logger.with(...)` for normal code, optional `LoggerContext` for framework code that wants the "library code still sees the request logger" trick. We want reviewer thoughts on which side of this tradeoff Ecstasy programs should be on. -### 3.3 Structured data — three concepts vs one +### 4.3 Structured data — three concepts vs one #### Aside: what is a log marker? @@ -616,7 +640,7 @@ Ecstasy program will probably want both — which the SLF4J model gives explicit the slog model gives via "the Text handler renders the message verbatim and appends attrs." -### 3.4 Message — `{}` template vs no interpolation +### 4.4 Message — `{}` template vs no interpolation SLF4J: `info("user {} did {}", [name, action])`. Lazy substitution: if the level is disabled the `format` step is skipped. Familiar and concise. @@ -635,7 +659,7 @@ separate attrs. Reusing the SLF4J formatter in `lib_slogging` would erase the cl part of slog's design by adding positional arguments next to attrs. If we want a migration helper later, it should be an adapter outside the core slog API. -### 3.5 Sink/handler shape — minimal vs richer +### 4.5 Sink/handler shape — minimal vs richer `LogSink` has two methods. Everything (level filter, attr resolution, MDC capture, formatting) happens above the sink. A new sink author writes `isEnabled` and `log` @@ -654,7 +678,7 @@ depends on workload. `lib_slogging` now ships derivation wrapper; a production backend can still override those hooks to cache a serialized prefix or backend-native context object. -### 3.6 Logger naming — hierarchical vs attribute-based +### 4.6 Logger naming — hierarchical vs attribute-based SLF4J's `logger.named("payments")` gives you a `Logger` whose name is `". payments"`. Hierarchical names are how Logback configuration ("set @@ -669,7 +693,7 @@ name-prefix matching. a feature: a `@Inject Logger` resolved with the enclosing module's qualified name is how SLF4J users intuitively expect it to work. slog forfeits this for uniformity. -### 3.7 Source location +### 4.7 Source location Both libraries now expose an explicit source-aware call as the lowering target for future compiler/runtime help: `logging.Logger.logAt(...)` populates @@ -680,14 +704,14 @@ Automatic call-site capture remains compiler/runtime polish. The library-level decision is made: source metadata belongs on the immutable event/record, not in a backend-specific side channel. -### 3.8 Async / batching +### 4.8 Async / batching Same shape in both: a wrapper handler/sink owns a bounded queue and drains on a worker fiber. The base libraries now ship these as `AsyncLogSink` and `AsyncHandler` so slow output can be isolated without waiting for a full configuration backend. -### 3.9 Familiarity +### 4.9 Familiarity A subjective dimension that nonetheless matters for adoption. SLF4J is the dominant JVM logging API; Java/Kotlin engineers reach for it without thinking. slog is the @@ -698,7 +722,7 @@ audience that will overlap heavily with both. ## 5. Ecstasy idiom fit — where the languages bite -### 4.1 `const` vs `service` for the building blocks +### 5.1 `const` vs `service` for the building blocks In `lib_logging`: @@ -722,7 +746,7 @@ not snapshot an MDC map on every event. The optional interaction — "I want a l that propagates through fibers without threading a parameter" — lives in `LoggerContext`, not in the hot path. -### 4.2 Fluent builder vs varargs +### 5.2 Fluent builder vs varargs SLF4J's fluent `.atInfo().setMessage(...).addKeyValue(...).log()` requires a `LoggingEventBuilder` interface and a `BasicEventBuilder` implementation, plus a @@ -739,7 +763,7 @@ how Ecstasy method signatures already work. But Ecstasy's default-argument suppo already collapses many of SLF4J's fluent-builder use cases into one method call, so the fluent surface is smaller in `lib_logging` than the SLF4J Java original. -### 4.3 Marker subgraph vs attribute uniformity +### 5.3 Marker subgraph vs attribute uniformity `Marker` in SLF4J/`lib_logging` is its own type with `add` (parent/child references) and `contains` (transitive query). It also has identity (`MarkerFactory.getMarker` @@ -752,7 +776,7 @@ handled by `Attr.of("audit", True)` and a handler that filters on that attr. slightly more efficient (one interned reference vs a string equality check), but the attribute model needs less surface area. -### 4.4 Compiler-side default-name injection +### 5.4 Compiler-side default-name injection SLF4J expects `LoggerFactory.getLogger(MyClass.class)` to resolve to a class- or module-named logger. `lib_logging` currently delegates that to a future compiler diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index 65db27eaed..015f0aab49 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -1,4 +1,4 @@ -# Open questions for `lib_logging` +# Logging decision tracker and open questions This is the running list of design decisions and remaining implementation questions, kept deliberately short so it stays readable as the project moves. diff --git a/doc/logging/usage/configuration.md b/doc/logging/usage/configuration.md index f5ae52eeaf..7e058985bc 100644 --- a/doc/logging/usage/configuration.md +++ b/doc/logging/usage/configuration.md @@ -8,6 +8,32 @@ debug-enabled. This is exactly the SLF4J/Logback split: SLF4J is the facade, Logback is the configured backend. The Ecstasy shape should preserve that split. +## Fast path + +The proposed XDK configuration model is: + +1. **JSON for the full backend graph.** XML is familiar from Logback, but JSON maps more + directly to typed Ecstasy config objects and existing XDK JSON tooling. +2. **Small overrides for operations.** Properties, environment variables, and command-line + args should patch common values such as root level, per-component level, file path, + cloud provider, and redacted keys. +3. **Stable injected loggers.** Runtime injection hands application code a logger whose + sink/handler is stable; a host-owned config service rebuilds the backend graph and + swaps the delegate on reload. +4. **Core vs follow-up modules.** The POC already implements the facade, SPI, JSON + console output, async wrappers, fanout, hierarchical routing, and test capture. File + destinations, rolling files, provider-specific cloud clients, JSON config parsing, and + hot reload belong in first-class follow-up XDK modules such as `lib_logging_config`, + `lib_logging_file`, and `lib_logging_cloud`. + +If you only need the shape, jump to +[`What is implemented vs proposed`](#what-is-implemented-vs-proposed), +[`Self-contained lib_logging dynamic backend sketch`](#self-contained-lib_logging-dynamic-backend-sketch), +and +[`Self-contained lib_slogging dynamic handler sketch`](#self-contained-lib_slogging-dynamic-handler-sketch). +The rest of this document is the detailed prior-art mapping for Logback, Log4j 2, +Spring Boot, and Go `slog`. + ## Configuration prior art to account for Logback is the best-known SLF4J backend, but it is not the only operational model that diff --git a/doc/logging/usage/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md index a69cdcf647..9a28dfabf5 100644 --- a/doc/logging/usage/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -5,7 +5,7 @@ goal is to be the file someone reads when they ask "okay, but what does it actua look like in real code?" The accompanying executable sample lives at -[`manualTests/src/main/x/TestLogger.x`](../../manualTests/src/main/x/TestLogger.x). +[`manualTests/src/main/x/TestLogger.x`](../../../manualTests/src/main/x/TestLogger.x). > **Status (2026-05).** Runtime injection of `@Inject Logger logger;` is wired in > the interpreter — `NativeContainer.ensureLogger` constructs a `BasicLogger` diff --git a/doc/logging/usage/structured-logging.md b/doc/logging/usage/structured-logging.md index 84bfd607cf..e865e0620a 100644 --- a/doc/logging/usage/structured-logging.md +++ b/doc/logging/usage/structured-logging.md @@ -8,6 +8,22 @@ event looks in Java SLF4J/Logback, Go `log/slog`, Ecstasy `lib_logging`, and Ecs human-readable message. Downstream systems can query fields directly instead of regexing text. +## Fast path + +The logging proposal treats structured fields as a hard requirement, not an optional +formatter trick: + +- `lib_logging` carries structured fields as `LogEvent.keyValues`, request context as + `MDC`, and categories as `Marker`; `JsonLogSink` renders those fields directly. +- `lib_slogging` carries the same information as `Attr` values; `JSONHandler` renders + attrs and nested groups directly. +- Both designs keep the human message stable and put machine-readable fields beside it, + so cloud logging, search, alerting, and audit pipelines do not need to parse text. + +The examples immediately below show the same payment event in Java SLF4J, Go `slog`, +Ecstasy `lib_logging`, and Ecstasy `lib_slogging`. The later sections explain the +implementation layers and migration rationale. + ## Why structured logging is mandatory now A modern logging framework is not just a better `print`. It feeds systems such as diff --git a/lib_logging/README.md b/lib_logging/README.md index d5f2d7a750..3f3e025e44 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -39,4 +39,4 @@ All design docs live at the repo root under [`doc/logging/`](../doc/logging): | [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) | SLF4J 2.x type and method mapping. | | [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) | Java SLF4J examples next to equivalent Ecstasy code. | | [`custom-sinks.md`](../doc/logging/usage/custom-sinks.md) | Guide to writing a custom sink. | -| [`open-questions.md`](../doc/logging/open-questions.md) | Remaining design and runtime decisions. | +| [`open-questions.md`](../doc/logging/open-questions.md) | Decision tracker and remaining runtime/compiler/backend follow-up. | From 6f1bcfdde581eeb443f67116f1c3ced1b598b355 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 10:27:05 +0200 Subject: [PATCH 16/28] Add logging branch review prompt --- doc/logging/README.md | 1 + doc/logging/review-prompt.md | 172 +++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 doc/logging/review-prompt.md diff --git a/doc/logging/README.md b/doc/logging/README.md index b622df600d..20e8db6400 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -114,6 +114,7 @@ The rest of the tree is organized by reader: | [`api-cross-reference.md`](api-cross-reference.md) | Official SLF4J / Go slog API links mapped to local Ecstasy types and difference notes. | | [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | | [`open-questions.md`](open-questions.md) | Decision tracker: resolved calls, designer questions (Q-D1..Q-D7), W-item parity list, and remaining follow-up. | +| [`review-prompt.md`](review-prompt.md) | Self-contained prompt for another AI agent to review the branch and documentation. | | **Design (`lib_logging` side)** | | | [`design/design.md`](design/design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | | [`design/why-slf4j-and-injection.md`](design/why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | diff --git a/doc/logging/review-prompt.md b/doc/logging/review-prompt.md new file mode 100644 index 0000000000..fec25a13bd --- /dev/null +++ b/doc/logging/review-prompt.md @@ -0,0 +1,172 @@ +# AI review prompt for the logging POC branch + +Use this prompt with any AI coding/review agent to review the entire +`lagergren/logging` branch, with special attention to whether the documentation clearly +teaches the proposed XDK logging design. + +```text +You are reviewing the `lagergren/logging` branch of the XDK repository. + +Your job is to perform a code-and-documentation review, not to rewrite the branch unless +explicitly asked. Treat this as a senior engineering review. Prioritize correctness, +clarity, missing rationale, misleading examples, broken links, implementation/documentation +drift, and places where a skeptical reviewer would get confused. + +Branch intent +------------- +This branch contains two parallel proof-of-concept logging libraries: + +- `lib_logging/`: SLF4J/Logback-shaped Ecstasy logging. This is currently recommended + as the canonical XDK logging facade. +- `lib_slogging/`: Go `log/slog`-shaped Ecstasy logging. This exists as a comparison + POC, not as a proposed permanent second default facade. + +The branch's central design claim is: + +1. XDK code should eventually have one injectable logging facade named `lib_logging`. +2. Application/library code should use `@Inject Logger logger;`. +3. The host/container should own backend policy by injecting or configuring a sink/handler. +4. Structured logging, dynamic backend configuration, JSON/cloud output, async fanout, + redaction, and test capture are requirements for a modern logging design. +5. The branch currently recommends the SLF4J-shaped API because named loggers, `{}` + formatting, markers, MDC, fluent builders, and Logback-style backend configuration are + familiar to the broadest likely audience. + +Review order +------------ +Start from `doc/logging/README.md`. It should be possible to understand the design at a +first-pass level by reading that file alone. Then follow the links in this order: + +1. `doc/logging/lib-logging-vs-lib-slogging.md` +2. `doc/logging/usage/structured-logging.md` +3. `doc/logging/usage/configuration.md` +4. `doc/logging/api-cross-reference.md` +5. `doc/logging/open-questions.md` +6. `lib_logging/README.md` +7. `lib_slogging/README.md` + +After the first pass, read the deep-dive docs as needed: + +- `doc/logging/design/design.md` +- `doc/logging/design/why-slf4j-and-injection.md` +- `doc/logging/design/xdk-alignment.md` +- `doc/logging/cloud-integration.md` +- `doc/logging/usage/injected-logger-example.md` +- `doc/logging/usage/ecstasy-vs-java-examples.md` +- `doc/logging/usage/slf4j-parity.md` +- `doc/logging/usage/slog-parity.md` +- `doc/logging/usage/custom-sinks.md` +- `doc/logging/usage/custom-handlers.md` +- `doc/logging/usage/platform-and-examples-adaptation.md` +- `doc/logging/future/logback-integration.md` +- `doc/logging/future/native-bridge.md` +- `doc/logging/future/lazy-logging.md` +- `doc/logging/future/runtime-implementation-plan.md` + +Also inspect the implementation enough to verify the docs: + +- `lib_logging/src/main/x/logging/` +- `lib_logging/src/test/x/LoggingTest/` +- `lib_slogging/src/main/x/slogging/` +- `lib_slogging/src/test/x/SLoggingTest/` +- `manualTests/src/main/x/TestLogger.x` +- `javatools/src/main/java/org/xvm/runtime/NativeContainer.java` + +Specific questions to answer +---------------------------- +1. Can a first-time reviewer understand from `doc/logging/README.md`: + - why two libraries exist in this branch, + - why only one should survive as the canonical `lib_logging`, + - what is already implemented, + - what is only sketched or future work, + - and why the branch currently recommends the SLF4J-shaped API? + +2. Does the documentation flow logically from overview to API comparison to structured + logging to configuration/backend details? Identify any document that forces a deep + dive before the reader has the basic model. + +3. Are verbose sections placed in the right deep-dive documents, with concise summaries + near the top? Flag any "wall of text" that should be summarized, moved, or split. + +4. Are all public claims about implemented functionality true in the code? + Check especially: + - `MessageFormatter` + - `Logger.logAt(...)` and source metadata + - `JsonLogSink` / `JsonLogSinkOptions` + - `CompositeLogSink`, `HierarchicalLogSink`, `AsyncLogSink` + - slog `JSONHandler`, `HandlerOptions`, `AsyncHandler`, `LoggerContext` + - runtime injection in `NativeContainer.java` + +5. Are proposed future pieces clearly labeled as future work? + Examples: config-file parser, property/env/CLI override merge, file and rolling-file + sinks, provider-specific cloud clients, automatic source capture, compiler-generated + logger names, and a Java Logback native bridge. + +6. Does the comparison between SLF4J/Logback and Go `slog` stay technically fair? + Verify especially: + - slog has attrs/groups/handlers/open levels but no first-class markers, + hierarchical logger-name tree, or message formatter. + - SLF4J/Logback has named loggers, markers, MDC, message templates, and mature + configuration, but a less uniform structured-data model. + - Java ecosystems such as Log4j 2, Flogger, Spring Boot logging, JUL, Commons + Logging, and logstash-logback-encoder are described accurately. + +7. Are Java/Go/Ecstasy examples readable, realistic, and clearly marked as either + implemented POC code or proposed API sketches? + +8. Is the XDK module story clear? + The expected direction is: + - core facade/SPI in the eventual `lib_logging`, + - config loading in a first-class follow-up module such as `lib_logging_config`, + - file destinations in a module such as `lib_logging_file`, + - cloud destinations either in `lib_logging_cloud` or provider-specific modules, + - runtime/compiler polish outside the logging library itself. + +9. Are there broken or stale local links, stale test counts, stale branch-status claims, + or references to old implementation plans that now conflict with the branch? + +10. Do the docs use requirement language consistently? Early requirements should use + MUST/MUST NOT where they are normative. + +Useful local checks +------------------- +Run these if the environment supports them: + +- `git status --short` +- `git log --oneline --decorate -20` +- `git diff --check` +- `./gradlew :xdk:lib-logging:compileXtc :xdk:lib-slogging:compileXtc --console=plain` +- `./gradlew :xdk:lib-logging:test :xdk:lib-slogging:test --console=plain` +- `./gradlew :javatools:compileJava :manualTests:runOne -PtestName=TestLogger --console=plain` + +For local Markdown links, use any equivalent link checker or script. A simple review is +enough if tooling is unavailable. + +Output format +------------- +Return findings first, ordered by severity: + +- `High`: incorrect implementation claim, misleading recommendation, broken core example, + or documentation flow that prevents understanding the proposal. +- `Medium`: important missing explanation, stale status, unclear implemented-vs-future + boundary, or incomplete comparison. +- `Low`: wording, formatting, small link/index issue, minor redundancy. + +For each finding, include: + +- file path and line number, +- the problem, +- why it matters, +- a concrete suggested fix. + +After findings, include: + +- "Reader-flow summary": whether the README -> comparison -> structured logging -> + configuration path works. +- "Implementation/doc drift": any mismatch between docs and code. +- "Suggested next edits": a short prioritized list. +- "Tests/checks run": commands run and results, or explain why they were not run. + +Do not produce a long rewrite unless asked. Focus on review signal. +``` + From 254073bb37f5be968707d7820154b2e2914bed66 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 10:29:25 +0200 Subject: [PATCH 17/28] Fix logging review prompt whitespace --- doc/logging/review-prompt.md | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/logging/review-prompt.md b/doc/logging/review-prompt.md index fec25a13bd..68384c37ff 100644 --- a/doc/logging/review-prompt.md +++ b/doc/logging/review-prompt.md @@ -169,4 +169,3 @@ After findings, include: Do not produce a long rewrite unless asked. Focus on review signal. ``` - From 5dfb49ac42637f03ae3d7a00a331e41775dd6b75 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 10:56:15 +0200 Subject: [PATCH 18/28] Fix logging POC review issues --- doc/logging/README.md | 12 +- doc/logging/design/xdk-alignment.md | 4 +- doc/logging/lib-logging-vs-lib-slogging.md | 7 ++ .../java/org/xvm/runtime/NativeContainer.java | 109 +++++++++++------- lib_logging/src/main/x/logging/AsyncLogSink.x | 2 +- lib_logging/src/main/x/logging/BasicMarker.x | 4 + .../src/main/x/logging/MarkerFactory.x | 10 +- .../src/test/x/LoggingTest/MarkerTest.x | 21 ++++ .../src/main/x/slogging/AsyncHandler.x | 2 +- 9 files changed, 120 insertions(+), 51 deletions(-) diff --git a/doc/logging/README.md b/doc/logging/README.md index 20e8db6400..d7f9f8e81b 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -10,6 +10,16 @@ one logging shape or the other, not both in the same design. Once the XDK settle injectable logging model, the winning API should be the XDK's `lib_logging`; the losing POC should either disappear or become an explicitly named adapter/comparison module. +There is no intended long-term build mode where the XDK is compiled "with logging" or +"with slogging" as alternate mutually exclusive standard libraries. For this POC, the +practical verification path is to build both `lib_logging` and `lib_slogging`, then run +the manual injection demo that exercises both `@Inject logging.Logger logger;` and +`@Inject slogging.Logger logger;`. The runtime wiring is deliberately tolerant while +the comparison exists: if only one of the POC modules is on a runner's module path, the +native container should register only that module's injected resources instead of +aborting startup. After review, the chosen API should be the only first-class +`lib_logging` facade. + ## First-pass summary Read this file first. It gives the whole design without requiring any deep dive: @@ -149,7 +159,7 @@ lib_logging/ SLF4J-shaped library │ ├── Level.x severity enum │ ├── Marker.x marker interface (org.slf4j.Marker) │ ├── BasicMarker.x default marker impl - │ ├── MarkerFactory.x factory service + │ ├── MarkerFactory.x stateful marker factory │ ├── MDC.x per-fiber mapped diagnostic context (const) │ ├── LogEvent.x immutable event const (Marker[] markers) │ ├── LogSink.x SPI — the API/impl boundary diff --git a/doc/logging/design/xdk-alignment.md b/doc/logging/design/xdk-alignment.md index 797d81bdf6..7e08f178b7 100644 --- a/doc/logging/design/xdk-alignment.md +++ b/doc/logging/design/xdk-alignment.md @@ -140,8 +140,8 @@ the right way; they just lack a `Logger` to round out the injection surface. | Named logger acquisition | `@Inject Logger logger; Logger named = logger.named("com.example");` | | `Const` for immutable records | `LogEvent` | | `const` for stateless/passable values | `BasicLogger`, `ConsoleLogSink`, `NoopLogSink`, `MDC`, `LogEvent` | -| `service` for stateful actors | `MarkerFactory`, `LoggerFactory`, `LoggerRegistry`, `MemoryLogSink` | -| `class` for mutable helpers/builders | `BasicEventBuilder`, `BasicMarker` | +| `service` for stateful actors | `LoggerFactory`, `LoggerRegistry`, `MemoryLogSink` | +| `class` for mutable helpers/builders | `BasicEventBuilder`, `BasicMarker`, `MarkerFactory` | The slog-shaped sibling follows the same injection convention: `slogging.Logger` is also registered under the resource name `logger`, and the native injector disambiguates diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index 0823e60a4c..a0354794a5 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -8,6 +8,13 @@ shape, not mix both as permanent XDK APIs. After review, the chosen injectable l shape should graduate as `lib_logging`; `lib_slogging` is a POC name for the Go `log/slog`-shaped candidate, not a proposal to keep a second default logging facade. +The branch does not introduce a clean XDK build switch between "logging" and +"slogging". During the POC, the meaningful validation is to compile both libraries and +run the manual injection test that uses both injected logger types. Runners that only +carry one POC module should still start; the native injector should simply expose the +resources for whichever logging module is present. That tolerance is a temporary +comparison aid, not a statement that both APIs should remain in the XDK. + | Library | Prior art | Core shape | |---|---|---| | `lib_logging` | SLF4J 2.x + Logback | Named loggers, `{}` message formatting, MDC, markers, fluent event builders, and a `LogSink` backend boundary. | diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index 16f2d681de..1a50dbdf54 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -111,19 +111,25 @@ private ConstantPool loadNativeTemplates() { ModuleStructure moduleRoot = f_repository.loadModule(ECSTASY_MODULE); ModuleStructure moduleTurtle = f_repository.loadModule(TURTLE_MODULE); ModuleStructure moduleNative = f_repository.loadModule(NATIVE_MODULE); - ModuleStructure moduleLogging = f_repository.loadModule("logging.xtclang.org"); - ModuleStructure moduleSLogging = f_repository.loadModule("slogging.xtclang.org"); + ModuleStructure moduleLogging = f_repository.loadModule(LOGGING_MODULE); + ModuleStructure moduleSLogging = f_repository.loadModule(SLOGGING_MODULE); - if (moduleRoot == null || moduleTurtle == null || moduleNative == null - || moduleLogging == null || moduleSLogging == null) { + if (moduleRoot == null || moduleTurtle == null || moduleNative == null) { throw new IllegalStateException("Native libraries are missing"); } + m_fLoggingModuleLoaded = moduleLogging != null; + m_fSLoggingModuleLoaded = moduleSLogging != null; + // "root" is a merge of "native" module into the "system" FileStructure fileRoot = new FileStructure(moduleRoot, true); fileRoot.merge(moduleTurtle, true, false); - fileRoot.merge(moduleLogging, true, false); - fileRoot.merge(moduleSLogging, true, false); + if (moduleLogging != null) { + fileRoot.merge(moduleLogging, true, false); + } + if (moduleSLogging != null) { + fileRoot.merge(moduleSLogging, true, false); + } fileRoot.merge(moduleNative, true, false); fileRoot.linkModules(f_repository, true); @@ -383,9 +389,11 @@ private void initResources(ConstantPool pool) { // TODO: This is not intended as permanent code. It is a hack for the POC of // injected SLF4J and slog logging and will likely be implemented differently // and in a more robust manner for the real logging implementation in the XDK. - // These back `@Inject Logger logger;`, `@Inject MDC mdc;`, and - // `@Inject slogging.Logger logger;` on the user side. The two logger resources use - // the same resource name, so getInjectable() must resolve by both name and type. + // When the corresponding POC modules are present, these back + // `@Inject Logger logger;`, `@Inject MDC mdc;`, and + // `@Inject slogging.Logger logger;` on the user side. The two logger resources + // use the same resource name, so getInjectable() must resolve by both name and + // type. // // The logger implementations are `const` types, deliberately NOT services: a service // wrapper around a logger would create a new fiber per call. That would break @@ -395,38 +403,42 @@ private void initResources(ConstantPool pool) { // // The supplier shape is the same as for any other native-injected resource: one // `(typeName, resourceName)` registration, no wildcard branch in `getInjectable`. - ModuleConstant modLogging = pool.ensureModuleConstant("logging.xtclang.org"); - // The injection key uses the user-facing interface type (`Logger`) — that's what - // `@Inject Logger logger;` resolves against. The supplier internally constructs a - // `BasicLogger` (the canonical implementation, a `const`) using that as the - // typeLogger argument to `findConstructor`. - TypeConstant typeLoggerIf = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modLogging, "Logger")); - TypeConstant typeLogger = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modLogging, "BasicLogger")); - m_typeLoggingLogger = typeLoggerIf; - m_typeBasicLogger = typeLogger; - addResourceSupplier(new InjectionKey("logger", typeLoggerIf), - (frame, hOpts) -> ensureLogger(frame, typeLogger, - inferLoggerName(frame, "logger"))); - - TypeConstant typeMDC = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modLogging, "MDC")); - addResourceSupplier(new InjectionKey("mdc", typeMDC), - (frame, hOpts) -> ensureConst(frame, typeMDC)); - - ModuleConstant modSLogging = pool.ensureModuleConstant("slogging.xtclang.org"); - TypeConstant typeSLogger = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modSLogging, "Logger")); - addResourceSupplier(new InjectionKey("logger", typeSLogger), - (frame, hOpts) -> { - ConstantPool poolFrame = frame.poolContext(); - TypeConstant typeFrameSLogger = poolFrame.ensureTerminalTypeConstant( - poolFrame.ensureClassConstant( - poolFrame.ensureModuleConstant("slogging.xtclang.org"), - "Logger")); - return ensureConst(frame, typeFrameSLogger); - }); + if (m_fLoggingModuleLoaded) { + ModuleConstant modLogging = pool.ensureModuleConstant(LOGGING_MODULE); + // The injection key uses the user-facing interface type (`Logger`) — that's what + // `@Inject Logger logger;` resolves against. The supplier internally constructs a + // `BasicLogger` (the canonical implementation, a `const`) using that as the + // typeLogger argument to `findConstructor`. + TypeConstant typeLoggerIf = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modLogging, "Logger")); + TypeConstant typeLogger = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modLogging, "BasicLogger")); + m_typeLoggingLogger = typeLoggerIf; + m_typeBasicLogger = typeLogger; + addResourceSupplier(new InjectionKey("logger", typeLoggerIf), + (frame, hOpts) -> ensureLogger(frame, typeLogger, + inferLoggerName(frame, "logger"))); + + TypeConstant typeMDC = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modLogging, "MDC")); + addResourceSupplier(new InjectionKey("mdc", typeMDC), + (frame, hOpts) -> ensureConst(frame, typeMDC)); + } + + if (m_fSLoggingModuleLoaded) { + ModuleConstant modSLogging = pool.ensureModuleConstant(SLOGGING_MODULE); + TypeConstant typeSLogger = pool.ensureTerminalTypeConstant( + pool.ensureClassConstant(modSLogging, "Logger")); + addResourceSupplier(new InjectionKey("logger", typeSLogger), + (frame, hOpts) -> { + ConstantPool poolFrame = frame.poolContext(); + TypeConstant typeFrameSLogger = poolFrame.ensureTerminalTypeConstant( + poolFrame.ensureClassConstant( + poolFrame.ensureModuleConstant(SLOGGING_MODULE), + "Logger")); + return ensureConst(frame, typeFrameSLogger); + }); + } } /** @@ -883,8 +895,8 @@ public FileStructure createFileStructure(ModuleStructure moduleApp) { // TODO CP/GG: that needs to be reworked (for now the order is critical) fileApp.merge(m_moduleTurtle, false, false); fileApp.merge(f_repository.loadModule("crypto.xtclang.org"), true, false); - fileApp.merge(f_repository.loadModule("logging.xtclang.org"), true, false); - fileApp.merge(f_repository.loadModule("slogging.xtclang.org"), true, false); + mergeOptionalModule(fileApp, LOGGING_MODULE); + mergeOptionalModule(fileApp, SLOGGING_MODULE); fileApp.merge(f_repository.loadModule("net.xtclang.org"), true, false); fileApp.merge(f_repository.loadModule("web.xtclang.org"), true, false); fileApp.merge(m_moduleNative, false, false); @@ -895,6 +907,13 @@ public FileStructure createFileStructure(ModuleStructure moduleApp) { return fileApp; } + private void mergeOptionalModule(FileStructure file, String sModule) { + ModuleStructure module = f_repository.loadModule(sModule); + if (module != null) { + file.merge(module, true, false); + } + } + // ----- helpers ------------------------------------------------------------------------------- @@ -1007,6 +1026,8 @@ public String toString() { private static final String ECSTASY_MODULE = Constants.ECSTASY_MODULE; private static final String TURTLE_MODULE = Constants.TURTLE_MODULE; private static final String NATIVE_MODULE = Constants.NATIVE_MODULE; + private static final String LOGGING_MODULE = "logging.xtclang.org"; + private static final String SLOGGING_MODULE = "slogging.xtclang.org"; private static final String TURTLE_PREFIX = "mack."; private static final int TURTLE_LENGTH = TURTLE_PREFIX.length(); private static final String NATIVE_PREFIX = "_native."; @@ -1027,6 +1048,8 @@ public String toString() { private ModuleStructure m_moduleSystem; private ModuleStructure m_moduleTurtle; private ModuleStructure m_moduleNative; + private boolean m_fLoggingModuleLoaded; + private boolean m_fSLoggingModuleLoaded; /** * Cached canonical logging types. Used to resolve module/class-named injections for diff --git a/lib_logging/src/main/x/logging/AsyncLogSink.x b/lib_logging/src/main/x/logging/AsyncLogSink.x index 1c5feea5e2..5a78c9fb5f 100644 --- a/lib_logging/src/main/x/logging/AsyncLogSink.x +++ b/lib_logging/src/main/x/logging/AsyncLogSink.x @@ -42,7 +42,7 @@ service AsyncLogSink(LogSink delegate, Int capacity) implements LogSink { @Override Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) { - return !closed && delegate.isEnabled(loggerName, level, marker); + return delegate.isEnabled(loggerName, level, marker); } @Override diff --git a/lib_logging/src/main/x/logging/BasicMarker.x b/lib_logging/src/main/x/logging/BasicMarker.x index dbe55e9031..d6da894f45 100644 --- a/lib_logging/src/main/x/logging/BasicMarker.x +++ b/lib_logging/src/main/x/logging/BasicMarker.x @@ -16,6 +16,10 @@ class BasicMarker(String name) */ @Override void add(Marker reference) { + if (reference.contains(this)) { + throw new IllegalArgument( + $"Adding marker reference {reference.name} would create a cycle with {name}"); + } if (!children.contains(reference)) { children.add(reference); } diff --git a/lib_logging/src/main/x/logging/MarkerFactory.x b/lib_logging/src/main/x/logging/MarkerFactory.x index 432c76cb5d..6779f58b72 100644 --- a/lib_logging/src/main/x/logging/MarkerFactory.x +++ b/lib_logging/src/main/x/logging/MarkerFactory.x @@ -1,15 +1,19 @@ /** * Corresponds to `org.slf4j.MarkerFactory` (the static-method facade in SLF4J) plus - * `org.slf4j.IMarkerFactory` (the interface SLF4J's facade delegates to). Logback ships - * `ch.qos.logback.classic.util.LogbackMDCAdapter`'s sibling marker factory; same idea. + * `org.slf4j.IMarkerFactory` (the interface SLF4J's facade delegates to). Logback's + * marker-aware filters consume the same marker objects. * * Factory for `Marker` instances. Mirrors `org.slf4j.MarkerFactory`. * * `getMarker(name)` is _interning_: two calls with the same name return the same instance. * `getDetachedMarker(name)` always returns a fresh instance, useful when a caller wants a * marker that is not visible to other callers. + * + * This is intentionally a stateful `class`, not a `service`: markers are mutable while a + * caller builds a marker DAG, and service-boundary passing would freeze/copy them before + * the caller could rely on SLF4J-style identity. */ -service MarkerFactory { +class MarkerFactory { private Map markers = new HashMap(); diff --git a/lib_logging/src/test/x/LoggingTest/MarkerTest.x b/lib_logging/src/test/x/LoggingTest/MarkerTest.x index 552d836564..140aa23a09 100644 --- a/lib_logging/src/test/x/LoggingTest/MarkerTest.x +++ b/lib_logging/src/test/x/LoggingTest/MarkerTest.x @@ -3,6 +3,8 @@ import logging.BasicMarker; import logging.Logger; import logging.Marker; +import xunit.assertions.assertThrows; + /** * Tests for `Marker` semantics: equality by name, transitive containment, and end-to- * end propagation through a `Logger` to a sink. @@ -38,6 +40,25 @@ class MarkerTest { assert !a.containsName("B"); } + @Test + void shouldRejectSelfReference() { + Marker a = new BasicMarker("A"); + + IllegalArgument e = assertThrows(() -> a.add(a)); + assert e.message.indexOf("cycle"); + } + + @Test + void shouldRejectReciprocalReference() { + Marker a = new BasicMarker("A"); + Marker b = new BasicMarker("B"); + + a.add(b); + + IllegalArgument e = assertThrows(() -> b.add(a)); + assert e.message.indexOf("cycle"); + } + @Test void shouldPropagateMarkerOnEvent() { ListLogSink sink = new ListLogSink(); diff --git a/lib_slogging/src/main/x/slogging/AsyncHandler.x b/lib_slogging/src/main/x/slogging/AsyncHandler.x index 9f1ad46f98..1bd992a9de 100644 --- a/lib_slogging/src/main/x/slogging/AsyncHandler.x +++ b/lib_slogging/src/main/x/slogging/AsyncHandler.x @@ -31,7 +31,7 @@ service AsyncHandler(Handler delegate, Int capacity) @Override Boolean enabled(Level level) { - return !closed && delegate.enabled(level); + return delegate.enabled(level); } @Override From 2d528dd2f425340ec3d206f062e997e7fdf4e34a Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 11:21:32 +0200 Subject: [PATCH 19/28] Clean up logging POC docs and wiring --- doc/logging/README.md | 18 +- doc/logging/api-cross-reference.md | 2 +- doc/logging/design/design.md | 51 +-- doc/logging/design/why-slf4j-and-injection.md | 5 +- doc/logging/future/logback-integration.md | 12 +- doc/logging/future/native-bridge.md | 232 ----------- .../future/runtime-implementation-plan.md | 386 ------------------ doc/logging/lib-logging-vs-lib-slogging.md | 11 +- doc/logging/open-questions.md | 27 +- doc/logging/review-prompt.md | 171 -------- doc/logging/usage/configuration.md | 53 +++ doc/logging/usage/injected-logger-example.md | 9 +- doc/logging/usage/slf4j-parity.md | 5 +- .../java/org/xvm/runtime/NativeContainer.java | 141 +++---- javatools_bridge/src/main/x/_native.x | 9 +- lib_logging/src/main/x/logging/AsyncLogSink.x | 11 + .../src/main/x/logging/CompositeLogSink.x | 14 + .../src/main/x/logging/ConsoleLogSink.x | 103 +++-- .../src/main/x/logging/HierarchicalLogSink.x | 11 + lib_logging/src/main/x/logging/JsonLogSink.x | 10 + lib_logging/src/main/x/logging/LogSink.x | 18 +- .../src/main/x/slogging/TextHandler.x | 68 ++- manualTests/src/main/x/TestLogger.x | 6 +- slf4j_full_guide.md | 377 ----------------- 24 files changed, 335 insertions(+), 1415 deletions(-) delete mode 100644 doc/logging/future/native-bridge.md delete mode 100644 doc/logging/future/runtime-implementation-plan.md delete mode 100644 doc/logging/review-prompt.md delete mode 100644 slf4j_full_guide.md diff --git a/doc/logging/README.md b/doc/logging/README.md index d7f9f8e81b..b62c9fa2f9 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -77,7 +77,7 @@ to JSON/cloud sinks without parsing message text. | Library | Prior art | Current proof | |---|---|---| -| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 64 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | +| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 66 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | | [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 37 focused XTC test methods, injected/manual coverage, async handler, handler options, and JSON/redaction support. | ## Recommendation @@ -111,7 +111,7 @@ The rest of the tree is organized by reader: | Go `log/slog` engineer | [`usage/slog-parity.md`](usage/slog-parity.md) | [`api-cross-reference.md`](api-cross-reference.md), [`lib_slogging` source](../../lib_slogging/src/main/x/slogging/) | | XTC language designer | [`open-questions.md`](open-questions.md) | [`design/design.md`](design/design.md), [`design/xdk-alignment.md`](design/xdk-alignment.md) | | Sink / handler author | [`usage/custom-sinks.md`](usage/custom-sinks.md), [`usage/custom-handlers.md`](usage/custom-handlers.md) | [`usage/structured-logging.md`](usage/structured-logging.md) | -| Cloud/backend reviewer | [`cloud-integration.md`](cloud-integration.md) | [`future/logback-integration.md`](future/logback-integration.md), [`future/native-bridge.md`](future/native-bridge.md) | +| Cloud/backend reviewer | [`cloud-integration.md`](cloud-integration.md) | [`future/logback-integration.md`](future/logback-integration.md), [`usage/configuration.md`](usage/configuration.md) | | Migration reviewer | [`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md) | [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | ## Full doc index @@ -124,7 +124,6 @@ The rest of the tree is organized by reader: | [`api-cross-reference.md`](api-cross-reference.md) | Official SLF4J / Go slog API links mapped to local Ecstasy types and difference notes. | | [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | | [`open-questions.md`](open-questions.md) | Decision tracker: resolved calls, designer questions (Q-D1..Q-D7), W-item parity list, and remaining follow-up. | -| [`review-prompt.md`](review-prompt.md) | Self-contained prompt for another AI agent to review the branch and documentation. | | **Design (`lib_logging` side)** | | | [`design/design.md`](design/design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | | [`design/why-slf4j-and-injection.md`](design/why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | @@ -142,9 +141,7 @@ The rest of the tree is organized by reader: | [`usage/configuration.md`](usage/configuration.md) | Logback XML vs proposed Ecstasy JSON configuration, dynamic sink/handler reload, and equivalence mapping. | | **Follow-up backend/compiler work** | | | [`future/logback-integration.md`](future/logback-integration.md) | Configuration-driven Logback-style backend on top of the shipped primitives. | -| [`future/native-bridge.md`](future/native-bridge.md) | Optional Java Logback bridge — feasibility analysis and why it is not the default path. | | [`future/lazy-logging.md`](future/lazy-logging.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | -| [`future/runtime-implementation-plan.md`](future/runtime-implementation-plan.md) | Mostly historical: the original runtime-wiring plan. Stages 1–3 have landed; the JIT-side equivalent (Stage 1) is open. | ## Where the actual code lives @@ -178,7 +175,7 @@ lib_logging/ SLF4J-shaped library │ ├── AsyncLogSink.x bounded async wrapper (service) │ ├── NoopLogSink.x drops every event (const) │ └── MemoryLogSink.x test-helper, captures events (service) - └── test/x/LoggingTest/ 64 focused XTC test methods + └── test/x/LoggingTest/ 66 focused XTC test methods lib_slogging/ slog-shaped sibling library ├── build.gradle.kts @@ -216,9 +213,8 @@ configuration-file loader, rolling-file/network destinations, and an optional Ja Logback bridge are deliberately not shipped in this branch; the docs explain where those belong if the canonical `lib_logging` API is accepted. -## Reference +## Backend boundary -The original SLF4J architecture writeup that shaped much of `lib_logging` is in -[`slf4j_full_guide.md`](../../slf4j_full_guide.md) at the repo root. It is the -source of the architectural template (`MyLangLogger` → `RuntimeLogSink` → -backend) we adapted into `Logger` → `LogSink` → backend. +This branch deliberately keeps the backend boundary pure XTC: `Logger` talks to +`LogSink`, and the implemented `LogSink` classes cover console, JSON, fanout, +hierarchical levels, async buffering, and test capture. diff --git a/doc/logging/api-cross-reference.md b/doc/logging/api-cross-reference.md index 6f3e95c710..3e7537a761 100644 --- a/doc/logging/api-cross-reference.md +++ b/doc/logging/api-cross-reference.md @@ -35,7 +35,7 @@ Primary references: | [`MessageFormatter`](../../lib_logging/src/main/x/logging/MessageFormatter.x) | SLF4J [`MessageFormatter`](https://www.slf4j.org/api/org/slf4j/helpers/MessageFormatter.html) | `{}` substitution, escape handling, and trailing-exception promotion. | Returns `(String, Exception?)`; an explicit `cause=` on the logger call wins over a promoted trailing exception. | | [`LoggingEventBuilder`](../../lib_logging/src/main/x/logging/LoggingEventBuilder.x), [`BasicEventBuilder`](../../lib_logging/src/main/x/logging/BasicEventBuilder.x) | SLF4J [`LoggingEventBuilder`](https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html) | Fluent `setMessage`, `addArgument`, `addMarker`, `setCause`, `addKeyValue`, `log`. | Supplier/lazy overloads are not implemented yet. The level check runs at `log(...)` so marker-aware sinks can participate. | | [`LogEvent`](../../lib_logging/src/main/x/logging/LogEvent.x) | SLF4J [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html), Logback `ILoggingEvent` | Immutable event payload carrying logger name, level, message, markers, cause, arguments, MDC, source metadata, and key/value pairs. | Ecstasy uses a `const`, with `Marker[] markers` plus `marker` convenience accessor and a `Map` for structured KV pairs. `sourceFile` / `sourceLine` are explicit today and ready for compiler call-site capture later. | -| [`LogSink`](../../lib_logging/src/main/x/logging/LogSink.x) | SLF4J provider boundary / Logback appender concept | Backend SPI: cheap enabled check plus event emission. | One interface allows both `const` forwarders and `service` stateful sinks; the `const`/`service` choice is documented in [`design/design.md`](design/design.md). | +| [`LogSink`](../../lib_logging/src/main/x/logging/LogSink.x) | Logback appender concept | Backend SPI: cheap enabled check plus event emission. | One interface allows both `const` forwarders and `service` stateful sinks; the `const`/`service` choice is documented in [`design/design.md`](design/design.md). | | [`ConsoleLogSink`](../../lib_logging/src/main/x/logging/ConsoleLogSink.x), [`NoopLogSink`](../../lib_logging/src/main/x/logging/NoopLogSink.x), [`MemoryLogSink`](../../lib_logging/src/main/x/logging/MemoryLogSink.x) | `slf4j-simple`, `slf4j-nop`, Logback `ListAppender`-style test capture | Minimal default output, silent output, and test capture. | Shipped as tiny Ecstasy sinks so the base library is useful without a Logback-style backend. | | [`JsonLogSink`](../../lib_logging/src/main/x/logging/JsonLogSink.x), [`JsonLogSinkOptions`](../../lib_logging/src/main/x/logging/JsonLogSinkOptions.x) | Logback JSON encoders such as LogstashEncoder / cloud appenders | One JSON object per event, rendered with `lib_json`, preserving MDC, markers, key/value pairs, exception structure, and source metadata. | Options are programmatic rather than XML: root threshold, redacted keys, redaction token, field names, and inclusion flags. | | [`CompositeLogSink`](../../lib_logging/src/main/x/logging/CompositeLogSink.x) | Multiple Logback appenders attached to one logger | Fans out an event to every delegate sink that enables it. | Composition is an ordinary `LogSink`, so callers and `BasicLogger` stay unchanged. | diff --git a/doc/logging/design/design.md b/doc/logging/design/design.md index c7f093b735..561c7f91dc 100644 --- a/doc/logging/design/design.md +++ b/doc/logging/design/design.md @@ -23,7 +23,6 @@ │ MemoryLogSink │ │ (future) configured/destination │ │ sinks │ - │ (optional future) native bridge │ └──────────────────────────────────┘ ``` @@ -31,17 +30,17 @@ User code holds a `Logger`. The `Logger` is a `BasicLogger` that holds a `LogSin sink is whatever the runtime injected. Everything above the `LogSink` line is sink-agnostic and stable; everything below is swappable. -This is the same architecture the SLF4J full guide describes (`slf4j_full_guide.md` at -the repo root, section "Architecture") — it is a deliberate decision to mirror it because -SLF4J got the layering right, and copying it gets us instant familiarity for free. +This mirrors the layering that makes SLF4J/Logback durable in Java: the public facade +is small, the backend boundary is narrow, and concrete output policy lives behind that +boundary. The Ecstasy version keeps that shape but makes injection the acquisition +mechanism and keeps `LogSink` as the XDK-native extension point. ## Why injection (and why per-name loggers come from `Logger.named(String)`) -Ecstasy already routes `Console` through `@Inject Console console;` — see -`lib_ecstasy/src/main/x/ecstasy/io/Console.x` and `javatools_jitbridge/.../TerminalConsole.java`. -The runtime controls which `Console` impl gets wired up. Loggers fit the same shape: -the runtime decides where output goes (in v0, always `ConsoleLogSink`; later, possibly a -configurable `LogbackLogSink`), and user code is sink-agnostic. +Ecstasy already routes `Console` through `@Inject Console console;`; the runtime +controls which `Console` implementation gets wired up. Loggers fit the same shape: the +runtime decides where output goes (in v0, `ConsoleLogSink`; later, possibly a +configured sink graph), and user code is sink-agnostic. `@Inject Logger logger;` resolves to a single fixed-name root logger — exactly one supplier (`("logger", loggerType)`) is registered with the injector. Per-name loggers @@ -54,9 +53,9 @@ static Logger PaymentLogger = logger.named("payments"); ``` This mirrors SLF4J's `LoggerFactory.getLogger(MyClass.class)` idiom one-to-one, and -keeps the injector's resource table as the single registry of allowed injections — -no type-only wildcard fallback, no special-case for `Logger`. See -`../future/runtime-implementation-plan.md` Stage 1.4 for the full rationale. +keeps the injector's resource table as the single registry of allowed injections: +there is one fixed `"logger"` resource, and per-name loggers are derived in XTC rather +than acquired through wildcard injection. ## API surface @@ -193,19 +192,6 @@ A separate module providing a configuration-driven sink. Reads a config tree The whole thing is just a different `LogSink` implementation. User code that already worked against `lib_logging` keeps working. Detailed sketch in `../future/logback-integration.md`. -## Native bridge — could we wrap real Logback? - -Yes, technically. `javatools_jitbridge` already imports `jline` (a third-party Java -library) for the terminal console, proving the bridge can carry external dependencies. -A `RTLogbackSink.java` extending `nService` and registered in -`nMainInjector.addNativeResources()` could wrap `org.slf4j.Logger` directly. SLF4J and -Logback are already in the version catalog (`lang-slf4j`, `lang-logback`), used by the -lang tooling. - -We don't recommend this as the primary path — see `../future/native-bridge.md` for the full -analysis — but it's a feasible escape hatch and worth documenting because it constrains -the design. - ## Per-container sink override Each Ecstasy container has its own injector. Host code that wants a nested @@ -274,20 +260,17 @@ Items deliberately *not* in scope for the base POC of `lib_logging`. the field name `"logger"` to the caller namespace for canonical `logging.Logger` injections. A compiler pass can still improve this by emitting the exact module/class logger name as the resource name. -- **External Logback-style configuration loader / native bridge** — the base backend +- **External Logback-style configuration loader** — the base backend primitives (`AsyncLogSink`, `CompositeLogSink`, `HierarchicalLogSink`, `JsonLogSink`) are in this module. XML/JSON config loading, rolling files, network destinations, and - any Java Logback bridge remain explicit follow-up modules; see - `../future/logback-integration.md` and `../future/native-bridge.md`. + filters remain explicit follow-up modules; see `../future/logback-integration.md`. - **Per-container override convenience** — open question 8. -The runtime-side injection wiring lives in +The temporary interpreter-side injection wiring lives in `javatools/src/main/java/org/xvm/runtime/NativeContainer.java` (`ensureLogger`, -`ensureConst`); `BasicLogger` is the `const` returned for `@Inject Logger`. The -earlier interpose service `xRTLogger.java` was removed in favour of constructing -`BasicLogger` directly so MDC fiber-locals survive injection (see Q-D5 in -`../open-questions.md`). The real `MessageFormatter` is implemented (12 tests in -`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (64 +`ensureConst`); `BasicLogger` is the `const` returned for `@Inject Logger` so MDC +fiber-locals survive injection. The real `MessageFormatter` is implemented (12 tests in +`MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (66 passing as of this commit). diff --git a/doc/logging/design/why-slf4j-and-injection.md b/doc/logging/design/why-slf4j-and-injection.md index 05e1ae1e0b..58cd94c74d 100644 --- a/doc/logging/design/why-slf4j-and-injection.md +++ b/doc/logging/design/why-slf4j-and-injection.md @@ -114,9 +114,8 @@ shape gets us: downstream consumers by their choice. A library that does logging is a library that does logging *politely*. - **An application author can swap `ConsoleLogSink` for `JsonLogSink`, - `CompositeLogSink`, `HierarchicalLogSink`, a future configured backend, or an - optional native bridge without recompiling any library.** Behind a stable injection - point. + `CompositeLogSink`, `HierarchicalLogSink`, or a future configured backend without + recompiling any library.** Behind a stable injection point. - **Test authors get `MemoryLogSink` for free.** They wire it up once and assert on `events`. No global state, no `LogManager.getLogManager().reset()`, no flaky tests. - **Future structured-logging consumers (log aggregators, dashboards) get a stable event diff --git a/doc/logging/future/logback-integration.md b/doc/logging/future/logback-integration.md index b62d38325d..1070099cd8 100644 --- a/doc/logging/future/logback-integration.md +++ b/doc/logging/future/logback-integration.md @@ -9,6 +9,10 @@ optional configuration-driven backend layer that could sit on top of those primi and add config files, richer appenders, filters, hot reload, rolling files, and destination-specific policies. +In other words: the branch already has programmatic Logback-style building blocks in +XTC. This document is about the missing production configuration layer around them, +not about whether Ecstasy can express Logback-like backend behavior at all. + ## What "Logback-style" means For SLF4J users, "Logback" means a specific bundle of features beyond the SLF4J facade: @@ -57,11 +61,9 @@ ConfiguredLogSink (the LogSink the runtime injects) └── Output (Console, File, RollingFile, Network, ...) ``` -This isn't novel — it's exactly Logback's mental model. The advantage of writing it as -an Ecstasy module rather than a wrapper around the JVM Logback library is that it gets -to use Ecstasy's own primitives (services for thread-safety, fibers for async, the file -abstraction from `lib_ecstasy`) instead of needing the bridge story discussed in -`../future/native-bridge.md`. +This isn't novel; it's Logback's mental model expressed as XTC code. The advantage of +writing it as an Ecstasy module is that it uses Ecstasy's own primitives: services for +thread-safety, fibers for async work, and the file abstraction from `lib_ecstasy`. ## Sketch — the public API of `lib_logging_logback` diff --git a/doc/logging/future/native-bridge.md b/doc/logging/future/native-bridge.md deleted file mode 100644 index 88077919d9..0000000000 --- a/doc/logging/future/native-bridge.md +++ /dev/null @@ -1,232 +0,0 @@ -# Native bridge — could we plug real Java logging libraries in? - -A research document. Investigates whether it's technically feasible — and whether it's -*advisable* — to ship `lib_logging` with a `LogSink` whose implementation is *native -Java code* wrapping the real `slf4j-api` / `logback-classic` libraries via Ecstasy's -existing JIT bridge. - -The short answer: - -- **Feasibility: high.** The bridge already does this for `jline`. Adding SLF4J + Logback - is a small engineering task. -- **Branch decision: don't ship it in this POC.** The pure-Ecstasy `LogSink` design is - the canonical path because it wins on portability, debuggability, and surface-area - minimisation. A native bridge remains an escape hatch for users who want to keep - existing Java Logback configuration assets unchanged. - -The rest of this doc walks through the evidence, the trade-offs, and what a native -bridge would look like if we built one later. - -## Evidence the bridge can carry external Java dependencies - -Ecstasy's runtime in `javatools_jitbridge/` already contains worked examples of native -services delegating to Java libraries. - -### TerminalConsole wraps `jline` - -The Ecstasy `Console` interface (`lib_ecstasy/src/main/x/ecstasy/io/Console.x`) is -implemented natively by `javatools_jitbridge/src/main/java/org/xtclang/_native/io/TerminalConsole.java`. -That class extends `nService` (the native-service base) and delegates to `xTerminalConsole`, -which imports `org.jline.reader` and `org.jline.terminal` from the third-party `jline` -library (declared in `gradle/libs.versions.toml` as `jline = "4.0.12"`). - -This proves three things: - -1. The bridge accepts third-party Java dependencies. -2. The build picks them up cleanly through the version catalog. -3. The pattern of "Ecstasy interface → native `nService` subclass → external Java - library" works end-to-end. - -### SLF4J and Logback are already in the catalog - -`gradle/libs.versions.toml`: - -```toml -lang-logback = "1.5.32" -lang-slf4j = "2.0.17" - -lang-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "lang-slf4j" } -lang-logback = { module = "ch.qos.logback:logback-classic", version.ref = "lang-logback" } -``` - -These are declared today for the lang/IDE tooling. They aren't currently consumed by -`javatools_jitbridge`, but adding them to `javatools_jitbridge/build.gradle.kts` is a -two-line change. - -## How native services bind to Ecstasy interfaces - -Concretely, the binding pattern (from inspecting `TerminalConsole.java`): - -- The Ecstasy `Console` interface declares `void print(Object object = "", Boolean - suppressNewline = False)`. -- The Java side implements a method `print$p(Ctx ctx, nObj object, boolean - suppressNewline, boolean dfltSuppressNewline)`. The `$p` suffix is convention; the - trailing `dflt*` booleans tell the native code which arguments were defaulted vs - passed explicitly. -- Argument marshalling: `String` becomes a custom Ecstasy `String` (UTF-8/compressed), - `Object` becomes `nObj`, primitive `Boolean` becomes Java `boolean`. To convert an - Ecstasy `Object` to a Java `String`, the native code calls `obj.toString(ctx)`. - -The bridge has no example yet of a native method receiving a heterogeneous `Object[]` -or invoking back into Ecstasy code. The architecture supports it (via -`nFunction.stdMethod.invokeExact`); it just hasn't been used. - -## Resource registration - -`javatools_jitbridge/src/main/java/org/xtclang/_native/mgmt/nMainInjector.java` line ~51: - -```java -suppliers.put(new Resource(consoleType, "console"), TerminalConsole::$create); -``` - -For `lib_logging`, we'd add: - -```java -suppliers.put(new Resource(loggerType, "logger"), RTLogger::$create); -suppliers.put(new Resource(logSinkType, "default"), RTLogbackSink::$create); -``` - -The interpreter-side `NativeContainer` now resolves by `(resource name, requested type)`; -the JIT bridge should follow the same rule if this experiment grows into a JIT-backed -logging bridge. - -The suppliers map is per-Injector instance, so each container can choose its own -sink. There is no JVM-global state; this matches Ecstasy's container isolation -philosophy. - -## What a native Logback-backed sink would look like - -`javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogbackSink.java`: - -```java -public class RTLogbackSink extends nService implements LogSink { - - public static RTLogbackSink $create(Ctx ctx, nObj opts) { - return new RTLogbackSink(); - } - - public boolean isEnabled$p( - Ctx ctx, - org.xtclang.ecstasy.text.String loggerName, - nObj level, - nObj marker, - boolean dfltMarker) { - org.slf4j.Logger l = org.slf4j.LoggerFactory.getLogger(loggerName.toString(ctx)); - return switch (toJavaLevel(level)) { - case TRACE -> l.isTraceEnabled(); - case DEBUG -> l.isDebugEnabled(); - case INFO -> l.isInfoEnabled(); - case WARN -> l.isWarnEnabled(); - case ERROR -> l.isErrorEnabled(); - }; - } - - public void log$p(Ctx ctx, nObj eventObj) { - // unpack eventObj into (loggerName, level, message, marker, exception, mdc, ts) - // call org.slf4j.LoggerFactory.getLogger(name).atLevel(level) - // .addMarker(...).setCause(...).log(message); - } -} -``` - -This wraps `org.slf4j.Logger` directly. Logback (already on the classpath) handles -configuration, filtering, appenders, layouts, async, rolling files — everything from -its existing `logback.xml`. - -For an Ecstasy host running this sink: -- Existing `logback.xml` files work as-is. -- Existing Logback expertise transfers directly. -- Existing operational tooling (log shippers expecting Logback's wire formats) keeps - working. - -## Trade-offs - -### Pros of going native - -| Pro | Detail | -|---|---| -| **Zero re-implementation cost** | We get Logback's filters, layouts, async, rolling, MDC rendering for free. The Java SLF4J/Logback ecosystem is mature; reproducing a fraction of it in pure Ecstasy is months of work. | -| **Existing config files just work** | Teams porting from Java keep their `logback.xml` and operational dashboards. | -| **Performance** | Logback is heavily tuned. Pure-Ecstasy reimplementations would take time to match. | -| **Battle tested** | Decades of production use means corner cases (rolling file mid-write, broken pipe on a TCP appender, GC pause behaviour) are known and handled. | - -### Cons of going native - -| Con | Detail | -|---|---| -| **JVM lock-in** | `lib_logging` becomes JVM-only at the native-sink layer. A future Ecstasy runtime (native, WASM, embedded) needs an equivalent native sink for that platform, or has to fall back to a pure-Ecstasy sink. | -| **Marshalling cost** | Every log call crosses the bridge: `Object → nObj`, then `nObj.toString(ctx)` to materialize a Java `String`. For high-volume disabled-level calls this is a measurable hit even after the level check. (Disabled calls don't cross; that's fine. The cost is on enabled calls.) | -| **Surface area** | Logback brings in dozens of classes, transitive deps, and configuration knobs we don't control. A bug in Logback becomes an Ecstasy-runtime bug from the user's perspective. | -| **Debug story** | Ecstasy stack traces stop at the bridge boundary; the inside of Logback shows up as opaque Java frames. Users debugging a misconfigured appender see Java-shaped errors, not Ecstasy-shaped ones. | -| **Configuration model coupling** | If we ship a native Logback sink, users will write `logback.xml`. If a future pure-Ecstasy `lib_logging_logback` ships, they have two ways to configure logging that look the same but don't interoperate. | -| **Bootstrap weirdness** | Logback initialises during classloading. The order of operations between Logback's static init and the Ecstasy injector's `addNativeResources()` call is fragile — solvable, but a footgun. | -| **Dependency churn** | We've now made every Ecstasy program depend transitively on `slf4j-api` + `logback-classic` (~3 MB of JARs). For programs that just want to log to console, that's overkill. | - -### Marshalling cost — concrete sketch - -For an enabled `info` call, the per-call cost crossing the bridge once would be roughly: - -- One nObj→Java String conversion for the logger name. -- One nObj→Java String conversion for the message (after Ecstasy-side `MessageFormatter` - does the substitution). -- One Object[]→Java `Object[]` conversion if SLF4J 2.x's `KeyValuePair` story is used. -- One MDC map copy across the bridge if the sink wants MDC. - -All allocating, all per-call. For a service emitting 10K log lines per second the -overhead is in the milliseconds-per-second range — non-trivial but not catastrophic. -For most applications the level check screens out the bulk of calls anyway. - -## Recommendation - -Three options, ordered by recommendation: - -### Option A (recommended): pure-Ecstasy `LogSink` is the primary path - -Ship `ConsoleLogSink` plus the base pure-Ecstasy backend primitives as the default. -Build any `lib_logging_logback` configuration module on top of those primitives. - -A native sink, if we ever ship one, is *one* of multiple `LogSink` choices, not the -default. Users who want the Java Logback experience opt in explicitly. - -This keeps `lib_logging` portable (any Ecstasy runtime, not just JVM-hosted), keeps -the dependency graph small, and keeps stack traces uniform. - -### Option B: native sink as a separate optional module - -Ship `lib_logging_native_logback` (or similar) as an optional XDK lib that pulls in -SLF4J + Logback. The runtime injector picks it up if present. Users who need -production-grade Logback today can use this; users who don't aren't paying for it. - -Concretely: a new lib_logging_native_logback module, a new -`javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogbackSink.java`, a -small change to `nMainInjector` to register it conditionally. Estimated work: under a -week of focused effort once the pure-Ecstasy `lib_logging` v0 is solid. - -### Option C: native sink as the default - -Don't recommend. It's the path of least immediate work but it pins us to the JVM and -inherits Logback's surface area into Ecstasy's contract. Hard to undo. - -## Conclusion - -The native bridge angle is worth knowing about because: - -1. It's technically feasible — the `jline` precedent is direct evidence. -2. It's a useful escape hatch for teams porting JVM workloads who have heavy - investment in `logback.xml` configurations they don't want to rewrite. -3. The existence of the option *constrains the API design* of `lib_logging` itself — - anything in the SLF4J 2.x surface that a native sink would want to forward to - `org.slf4j.Logger.atInfo()...log()` should be expressible in our `LoggingEventBuilder`. - We've designed for that. - -But: the long-term answer is a pure-Ecstasy implementation. We get there incrementally: -ship the API, ship the base backend primitives, add destination/configuration modules -written in Ecstasy, and optionally ship a native bridge for legacy interop. - -The most important property is that none of these decisions break caller code. The -`Logger` interface and the `LogSink` boundary make all of this swappable. - - ---- - -_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/future/runtime-implementation-plan.md b/doc/logging/future/runtime-implementation-plan.md deleted file mode 100644 index 6903dd96e0..0000000000 --- a/doc/logging/future/runtime-implementation-plan.md +++ /dev/null @@ -1,386 +0,0 @@ -# Runtime implementation plan — historical runtime wiring plan - -> **Status (2026-05): Stages 1–3 are landed.** The runtime now resolves -> `@Inject Logger logger;` end-to-end; the demo described in §"Demo target" -> works as written. **The plan diverged from this document in one -> way worth flagging up front:** there is no separate `RTLogger.java` / -> `xRTLogger`. `BasicLogger` is a `const`, and the runtime constructs it -> directly in `javatools/.../NativeContainer.java` (`ensureLogger` / -> `ensureConst`). The reason — collapsing the service-wrapper indirection so -> per-fiber `MDC` (`SharedContext`) survives injection — is documented as -> question Q-D5 in `../open-questions.md`. The stage descriptions below describe -> the *original* approach for historical context; treat them as background, not -> as instructions for current work. Stage 4 (compiler-side default name) -> remains open. - -This historical plan described how to turn the original v0 stub (`@Inject Logger logger;` -parsed but did not resolve) into a **working end-to-end demo**: the Ecstasy line - -```ecstasy -@Inject Logger logger; -logger.info("hello {}", ["world"]); -``` - -producing real output on the platform `Console` via the runtime injector. Every item -here either is on the critical path to that demo, or is needed to call the round trip -"complete" (parameterized messages actually substitute, MDC actually propagates, etc.). - -The doc supersedes `../open-questions.md` items 1, 2, 3, 5, 9, 10 by prescribing concrete -fixes; the others remain genuinely open. - -## Demo target - -The runtime demo was considered complete when all of these were true: - -1. A standalone Ecstasy program containing only - ```ecstasy - module Demo { - package log import logging.xtclang.org; - void run() { - @Inject log.Logger logger; - logger.info("hello {}", ["world"]); - } - } - ``` - compiles, runs, and prints a single timestamped line containing `hello world` - without any explicit `BasicLogger` / `LogSink` wiring. - -2. The same demo with `Logger demo = logger.named("com.example"); demo.info(...);` - produces a logger named `com.example` (output line carries the name). Per-name - loggers are derived from the injected one, not injected directly — see Stage 1.4 - for why `@Inject("…") Logger` is *not* the chosen API shape. - -3. `logger.error("failed: {}", [id], cause=new Exception("boom"));` emits both the - message line and the exception text. - -4. `logger.atInfo().addMarker(AUDIT).addKeyValue("k", v).log("msg")` works through the - fluent builder. - -5. MDC `mdc.put("requestId", id)` shows up alongside any subsequent emission; both - `ConsoleLogSink` and `JsonLogSink` render the captured MDC snapshot. - -6. The focused `lib_logging` suite (64 XTC test methods) still compiles cleanly; - `manualTests/src/main/x/TestLogger.x` invokes both `runInjected()` and the - slog-shaped `runInjectedSlog()` path successfully. - -7. `~/src/platform/kernel/kernel.x` compiles unchanged (it does not use - `@Inject Logger` yet) AND a small follow-up PR converting one of its - `console.print($"... Info :")` calls to `logger.info(...)` works end-to-end. - -If all seven hold, we can demo `lib_logging` to anyone. - -## Critical path - -These items are sequenced by hard dependency. - -### Stage 1 — Native side: make `@Inject Logger` resolve - -Three native files in `javatools_jitbridge/`, plus a registration line in -`nMainInjector`. Mirrors the `TerminalConsole.java` pattern exactly. - -#### 1.1 — `RTLogger.java` - -`javatools_jitbridge/src/main/java/org/xtclang/_native/logging/RTLogger.java`. - -A native service that wraps an Ecstasy `BasicLogger` instance. There is exactly one -registration: `(loggerType, "logger")`. The `$create(Object opts)` factory always -constructs the root logger named `"logger"`; per-name children are obtained from it -via `Logger.named(String)` (see Stage 1.4 for why we don't accept `@Inject("…")`). - -- Constructor takes `(String name, LogSink sink)`. The `sink` argument resolves via - the same injector — this is the second native registration below. -- Implements every method on the Ecstasy `Logger` interface using the `$p` suffix - convention (`info$p(Ctx, String, Array, Exception?, Marker?)`, etc.). -- All emission methods delegate to an inner pure-Ecstasy `BasicLogger` constructed - once per resource name. - -#### 1.2 — `RTConsoleLogSink.java` - -`javatools_jitbridge/.../logging/RTConsoleLogSink.java`. - -The native default sink. Wraps `ConsoleLogSink` (Ecstasy) and is what -`@Inject LogSink defaultSink` resolves to when no application override is registered. -Bootstrap-safe: even if `Console` is not yet registered, falls back to -`System.err.println` so first-line logging during early init can never deadlock. - -#### 1.3 — `RTMarkerFactory.java`, `RTMDC.java` - -Same pattern, smaller. Both wrap their Ecstasy counterparts. `RTMDC` needs a per-fiber -storage decision — see Stage 2 below; until that's resolved, it holds a per-instance -`HashMap` and we accept that MDC is per-container, not per-fiber. - -#### 1.4 — Single fixed-name supplier; no wildcard injection - -**Resolved against wildcard.** The earlier draft of this plan called for a wildcard -`(loggerType, "*")` fallback so that `@Inject("com.example") Logger logger;` would -resolve to a per-name logger. That shape was prototyped in `NativeContainer` (interpreter -side) and then deliberately removed. The chosen design instead: - -- Register exactly one supplier in `NativeContainer.initResources` / - `nMainInjector.addNativeResources`: `("logger", loggerType)` → root `Logger`. -- Expose `Logger.named(String)` on the public API. Per-name loggers are *derived*, not - injected: `@Inject Logger logger; Logger payments = logger.named("payments");`. -- `getInjectable` stays untouched. There is no type-only special-case in the runtime; - the supplier table remains the single registry of allowed injections. - -This is the same call-site shape SLF4J users already write in Java -(`LoggerFactory.getLogger(MyClass.class)`), so it does not cost ergonomics relative to -the SLF4J baseline. It does cost the spelling `@Inject("name") Logger logger;`, which -has no actual SLF4J equivalent and was a `lib_logging` invention. - -##### Why we rejected wildcard injection - -1. **Special-case at the deepest layer of the runtime.** A type-only fallback inside - `NativeContainer.getInjectable` (or `nMainInjector.supplierOf`) means the supplier - table no longer answers "what can be injected here?" without consulting a hidden - per-type bypass. -2. **One customer.** The whole wildcard mechanism was being built for `Logger`. A - feature that special-cases the runtime for a single library type is hard to justify. -3. **Imagined SLF4J parity.** SLF4J in Java does not use parameterized injection at all - — its idiom is `LoggerFactory.getLogger(MyClass.class)`. The `@Inject("name")` form - was a `lib_logging` invention pitched as "SLF4J ergonomics" but is in fact a *more* - concise spelling than SLF4J actually offers. The cost-vs-benefit didn't pencil out. -4. **The "future generalisation" argument has no second customer.** Promoting wildcard - to a first-class `Injector` API only pays off if a second library wants type-only - injection. None is in sight. - -##### Cost of the chosen design - -One extra line per class that wants a per-name logger: - -```ecstasy -@Inject Logger logger; // injected once -static Logger PaymentLogger = logger.named("payments"); // derived -``` - -That line is what every Java SLF4J user writes today. We accept it. - -##### Alternatives that were considered and rejected - -- **`@Inject(opts="com.example") Logger logger;`** — spelling is awkward and still - routes a single-supplier lookup with the real identity hidden in `opts`. -- **Compiler-substituted module name** (Stage 4) — useful *complement* (auto-defaults - the per-module logger name), but doesn't address per-class loggers within a module. - Track separately if it lands. -- **Pure `LoggerFactory.getLogger(...)` with no `@Inject Logger` at all** — gives up - the injection ergonomic without buying anything; the per-name problem just moves - one level out. - -#### 1.5 — Registration in `addNativeResources()` - -The interpreter-side equivalent is already in place: - -```java -xRTLogger templateLogger = xRTLogger.INSTANCE; -TypeConstant typeLogger = templateLogger.getCanonicalType(); -addResourceSupplier(new InjectionKey("logger", typeLogger), - (frame, hOpts) -> templateLogger.ensureLogger(frame, "logger", hOpts)); -``` - -For the JIT injector, the analogous addition: - -```java -suppliers.put(new Resource(loggerType, "logger"), RTLogger::$create); -suppliers.put(new Resource(logSinkType, "default"), RTConsoleLogSink::$create); -suppliers.put(new Resource(markerFactoryType, "markers"), RTMarkerFactory::$create); -suppliers.put(new Resource(mdcType, "mdc"), RTMDC::$create); -``` - -`loggerType` etc. are resolved via the existing TypeConstant lookup machinery the same -way `consoleType` is. Note the `(loggerType, "logger")` exact-name entry — no wildcard. - -**Done state of Stage 1:** `@Inject Logger logger;` returns a working root `Logger` -instance. `Logger.named(String)` derives per-name children. Tests pass. The -`runInjected()` and `runInjectedByName()` paths of `manualTests/.../TestLogger.x` both -print to the console. - -### Stage 2 — Make the message actually format - -Right now `logger.info("hello {}", ["world"])` emits the literal `hello {}`. Fixing -this is a pure-Ecstasy port of SLF4J's `MessageFormatter.format` state machine. - -#### 2.1 — Real `MessageFormatter.format` - -Port the algorithm from `org.slf4j.helpers.MessageFormatter` (it's a small, well- -defined state machine). Specifically: - -- Walk the `message` character by character. -- Track escape state: `\{` is a literal `{`, `\\{` is a literal backslash followed by a - placeholder. -- For each unescaped `{}`, consume the next argument's `toString()` and append. -- Excess placeholders left literal; excess arguments dropped. -- If the last argument is an `Exception` and there is no remaining placeholder, - promote it to the returned `cause`. - -#### 2.2 — Tests - -Port a subset of SLF4J's `MessageFormatterTest` cases — empty patterns, single -placeholder, multi placeholder, escape handling, throwable promotion, mismatched -counts. ~10 cases is enough to be confident. - -#### 2.3 — Wire into BasicLogger - -Already wired (`BasicLogger.emit` calls `MessageFormatter.format`). Once the formatter -is real, the wiring works. - -**Done state of Stage 2:** `logger.info("hello {}", ["world"])` outputs -`... INFO Demo: hello world`. The throwable-promotion rule handles -`logger.warn("cleanup failed", [], cause=e)` correctly. - -### Stage 3 — Re-enable MDC - -#### 3.1 — Decide the scope - -Pick one: - -- **a) Per-fiber.** Matches Java ThreadLocal MDC. Requires the runtime to give us - fiber-local storage. The `RTMDC` native impl uses that. -- **b) Per-service-instance.** Cheap to implement; semantically wrong for typical - request-scoped use, because anything inside a non-trivial fiber tree shares a single - MDC service, so concurrent requests would step on each other. - -Recommendation: **(a)** if the runtime can support it — there is a known pattern in -the JVM bridge for context-local state. If not, ship (b) for v0 with a clear "this is -known-incorrect for concurrent use" warning in the doc, and revisit before any real -production user picks this up. - -#### 3.2 — Re-enable `@Inject MDC mdc;` in `BasicLogger` - -The injection was removed in v0 because the runtime didn't register the resource. Once -Stage 1 lands, restore the field and replace the `new HashMap()` in -`emit()` with `mdc.copyOfContextMap`. - -#### 3.3 — Render MDC in `ConsoleLogSink` - -One-liner: append `[mdc=k1=v1,k2=v2]` to the formatted line if `event.mdcSnapshot` is -non-empty. - -**Done state of Stage 3:** `mdc.put("requestId", id)` followed by -`logger.info("dispatch")` produces a line containing `[mdc=requestId=...]`. - -### Stage 4 — Compiler-side default name (optional but high-impact) - -Today `@Inject Logger logger;` (no `resourceName`) resolves to the root logger -literally named `"logger"` — the field-name fallback. Many SLF4J users find the -enclosing module/class name more useful as the default. The fix is a small XTC -compiler change. - -#### 4.1 — Substitute the enclosing module name - -When the compiler sees `@Inject Logger logger;` with `resourceName == Null`, -substitute the enclosing module's qualified name as the resource name. This is one -location in the compiler; the dispatch on type `Logger` is the only special case. - -#### 4.2 — Document - -Update `../usage/injected-logger-example.md` and `../usage/slf4j-parity.md` to reflect the implicit- -naming behaviour. - -**Done state of Stage 4:** `@Inject Logger logger;` inside `module PaymentService` -emits lines tagged `PaymentService:`. The cheat sheet in -`../usage/injected-logger-example.md` becomes accurate without `@Inject("PaymentService")`. - -This stage is **optional for the demo**. Without it, callers write -`@Inject Logger logger; Logger paymentLogger = logger.named("PaymentService");`. With -it, `@Inject Logger logger;` alone is already named after the enclosing module. - -### Stage 5 — Validation: platform repo migration - -The end-to-end test is migrating real code. - -#### 5.1 — Pick three files in `~/src/platform` - -`kernel/kernel.x`, `auth/OAuthProvider.x`, `host/HostManager.x` are the highest- -density `console.print($"... Info :")` files. See `../usage/platform-and-examples-adaptation.md`. - -#### 5.2 — Migrate each - -Mechanical: replace `console.print($"{common.logTime($)} Info : ...")` with -`logger.info("...")`. Drop the `common.logTime` helper after the last caller is -gone. - -#### 5.3 — Confirm output - -Run the platform; confirm log lines look as expected. Capture before/after samples for -the demo. - -**Done state of Stage 5:** the platform repo's logging looks identical from outside -(maybe slightly cleaner); from inside, every log site is now an `@Inject Logger` -call. The before/after diff is the demo. - -## Open questions still open after this plan - -The plan above closes runtime-side gaps. These items remain genuinely unresolved and -need a decision separately: - -- **Multiple markers per event** (`../open-questions.md` #4). API-level: should we change - `LogEvent.marker: Marker?` to `LogEvent.markers: Marker[]` and update the fluent - builder to accumulate? Cheap to do now, breaks callers later. -- **Service vs class for sinks** (`../open-questions.md` #6). Currently the recommendation - is "don't require service"; the question is whether to formalize that in the - interface signature. -- **Async / batched sinks** (`../open-questions.md` #7). Defer to `lib_logging_logback`? - Or ship a simple `AsyncLogSink` wrapper here? -- **Per-container override convenience** (`../open-questions.md` #8). Probably "no - helper, document the pattern" — but worth deciding before there are users. -- **Defensive copy of caller `Object[]`** (`../open-questions.md` #11). Now that args are - frozen-on-the-way-into-the-event for builder calls, the per-level methods (which - accept caller-supplied arrays) might still see mutation. Decide: copy in - `BasicLogger.emit` always, or document caller-must-not-mutate. -- **Lazy logging lambdas** (`../future/lazy-logging.md`). When do we add the - `info(function String () messageFn, ...)` overloads? Easy to add; question is - timing. -- **Structured `keyValues` field on `LogEvent`** (`../usage/structured-logging.md`). Add now or - with the first sink that consumes them? - -## Recommended sequencing - -``` -Stage 1.1 RTLogger.java ┐ -Stage 1.2 RTConsoleLogSink.java ├─ same PR, ~half a day -Stage 1.3 RTMarkerFactory + RTMDC │ -Stage 1.4 wildcard resolution │ -Stage 1.5 register in nMainInjector ┘ - │ - ▼ demo runs but message says "hello {}" -Stage 2 MessageFormatter port + tests ── ~half a day - │ - ▼ demo says "hello world" -Stage 3 MDC re-enable + render ── ~2 hours - │ - ▼ full v0.1 round trip -Stage 4 compiler-side default name ── ~half a day, separate PR - │ - ▼ feels like SLF4J -Stage 5 platform migration ── ~1-2 days, follow-up PR -``` - -Original estimate: **2-3 working days**, split across one runtime PR and one -follow-up that polishes message formatting and MDC. The compiler change and the -platform migration are independent and can land later. - -## What this plan does *not* cover - -- The future `lib_logging_logback` module (`../future/logback-integration.md`). The - base programmatic primitives now exist; a configurable backend remains a separate - larger project. -- The native-Logback bridge approach (`../future/native-bridge.md`). Documented as feasible but - not the primary path. -- Slog-style alternative API (`ALTERNATIVE_DESIGN_SLOG_STYLE.md`). Documented as a - thinkable alternative but not pursued. -- Performance tuning beyond the shipped async wrappers. Revisit when the platform repo - has been on `lib_logging` for long enough that we have data. - -## Historical estimate - -The original plan expected a small runtime slice for `@Inject Logger`, a smaller -Ecstasy slice for SLF4J `{}` substitution, and an optional compiler slice for -module-scoped default logger names. The first two concerns have since landed in a -different shape: `NativeContainer` constructs the injectable `BasicLogger` directly, -and `MessageFormatter` now handles `{}` substitution. Compiler-side default naming -remains separate from the manual demo and can land later. - - ---- - -_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index a0354794a5..ae3616e2e3 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -32,7 +32,7 @@ decide which shape survives. Direct links from each Ecstasy type to its SLF4J or Go `log/slog` counterpart are in [`api-cross-reference.md`](api-cross-reference.md). > Status note: both libraries are working comparison POCs. `lib_logging` is the -> recommended canonical facade and has the fuller SLF4J surface (64 focused XTC test +> recommended canonical facade and has the fuller SLF4J surface (66 focused XTC test > methods plus the injected manual demo), including async/composite/hierarchical/JSON > backend building blocks. `lib_slogging` has the more compact slog surface (37 focused > XTC test methods plus injected/manual coverage), with runtime injection, async @@ -786,9 +786,10 @@ attribute model needs less surface area. ### 5.4 Compiler-side default-name injection SLF4J expects `LoggerFactory.getLogger(MyClass.class)` to resolve to a class- or -module-named logger. `lib_logging` currently delegates that to a future compiler -change (Stage 4 in `future/runtime-implementation-plan.md`). Without it, `@Inject Logger` -gets a fixed-name root logger and you call `.named("...")` to derive children. +module-named logger. `lib_logging` currently handles the simple interpreter case with +a runtime fallback from the injected field name to the caller namespace. Exact +compiler-synthesized names remain future polish. Without them, `@Inject Logger` gets a +fixed-name root logger and you call `.named("...")` to derive children. slog has no such machinery — there is one process-wide default Logger and you derive from it. No naming hierarchy, no compiler change. @@ -901,7 +902,7 @@ review material needed to choose between them. | Area | Contents | |---|---| -| `lib_logging/` | Recommended canonical SLF4J-shaped library with 64 focused XTC test methods, runtime injection, source metadata API, JSON/redaction sink, async wrapper, composite fanout, and hierarchical per-logger thresholds. | +| `lib_logging/` | Recommended canonical SLF4J-shaped library with 66 focused XTC test methods, runtime injection, source metadata API, JSON/redaction sink, async wrapper, composite fanout, and hierarchical per-logger thresholds. | | `lib_slogging/` | slog-shaped sibling library with 37 focused XTC test methods, runtime injection, `lib_json` JSON rendering, handler options/redaction, async wrapper, explicit source metadata, `LoggerContext`, handler derivation, and handler contract tests. | | `doc/logging/` | Design comparison, API cross-reference, language/runtime questions, usage guides, and backend follow-up sketches. | diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index 015f0aab49..be08f2e84c 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -4,8 +4,7 @@ This is the running list of design decisions and remaining implementation questi kept deliberately short so it stays readable as the project moves. It is split into resolved design notes, implementation trackers, and the few items -that still need compiler/tooling/backend follow-up. Historical plan links are kept -where they explain why the branch made a decision. +that still need compiler/tooling/backend follow-up. --- @@ -13,10 +12,10 @@ where they explain why the branch made a decision. | # | Question | Resolution | |---|---|---| -| 1 | **Wildcard-name injection in `nMainInjector`** — `@Inject("any.name") Logger` doesn't resolve because the existing resource map is exact-match. | **Rejected.** Single fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. No special-case in the injector. See [Stage 1.4](future/runtime-implementation-plan.md#14--single-fixed-name-supplier-no-wildcard-injection). | -| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | Runtime fallback implemented for canonical `logging.Logger`: if the injected field name is `"logger"`, `NativeContainer` derives the caller namespace. Exact compiler-synthesized module/class names remain future polish. See [Stage 4](future/runtime-implementation-plan.md#stage-4--compiler-side-default-name-optional-but-high-impact). | +| 1 | **Wildcard-name injection** — should `@Inject("any.name") Logger` acquire arbitrary named loggers directly? | **Rejected.** There is one fixed-name `("logger", loggerType)` supplier; per-name loggers come from `Logger.named(String)` instead. | +| 2 | **Default logger name when no `resourceName` supplied** — should `@Inject Logger logger;` (no name) get `"default"`, the enclosing module's name, or the class name? | Runtime fallback implemented for canonical `logging.Logger`: if the injected field name is `"logger"`, `NativeContainer` derives the caller namespace. Exact compiler-synthesized module/class names remain future polish. | | 3 | **MDC scope: per-fiber, per-service, or per-call?** | Per-fiber semantics implemented in Ecstasy with `SharedContext`. The logger is injected as a `BasicLogger` const so calls stay on the caller fiber and can see the current MDC. | -| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. See [Stage 2.1](future/runtime-implementation-plan.md#21--real-messageformatterformat). | +| 5 | **Throwable promotion: where does it happen, and who wins when both are supplied?** | `MessageFormatter.format` does the promotion; explicit `cause=` always wins over a promoted-from-args throwable. | | 9 | **Where does the runtime live?** | Interpreter-side wiring lives in `javatools/src/main/java/org/xvm/runtime/NativeContainer.java`. The earlier native-resource class plan is superseded for this branch. | | 10 | **Bootstrap: do we need a tiny native fallback for early-runtime logging before `Console` is registered?** | Not for this branch. The default `ConsoleLogSink` writes through `@Inject Console`; a bootstrap-native fallback can be added later only if runtime startup logging needs it. | @@ -151,19 +150,6 @@ context propagation, or should we be using a different primitive (a built-in `MDC`-equivalent, a service-injected ledger, fiber-local slots) for SLF4J-grade MDC semantics? -### Q-D5. Removing the `RTLogger.java` wrapper — is there a class of "injection-only" -runtime resources where the right answer is "construct the const directly, don't wrap"? - -The original POC went through `xRTLogger.INSTANCE` (a service template wrapping a -`Logger`). That wrapper crossed a fiber boundary and severed the caller's MDC tokens. -Removing the wrapper and registering `BasicLogger` directly as the resource (see -`NativeContainer.ensureLogger`) fixed the problem and simplified the runtime side. - -**Question:** is this generally the right pattern when (a) the injected type is a -`const`, (b) it has no per-call native state, and (c) we want fiber-local context -visibility to survive injection? If so, should `nMainInjector` grow a -`registerConstResource(...)` helper to make this the documented happy path? - ### Q-D6. SLF4J vs slog as the API shape — *resolved for this branch* We're maintaining two parallel libraries (`lib_logging` modeled after SLF4J 2.x and @@ -216,7 +202,6 @@ feature scope. Each item is a concrete deliverable with a one-line summary. | W-4 | Default injected logger naming | **Partially done.** `NativeContainer` now falls back from the field name `"logger"` to the caller namespace for canonical `logging.Logger` injections. Exact module/class names still need compiler lowering if we want deterministic names outside the runtime fallback. | | W-5 | Async / batched sink (`AsyncLogSink`) | **Done.** Bounded queue + worker-fiber drain wrapper ships in the base library and is tested. | | W-6 | Logback-shaped backend primitives | **Done at the base-programmatic level.** `CompositeLogSink`, `HierarchicalLogSink`, `JsonLogSink`, and `JsonLogSinkOptions` cover multi-appender fanout, per-logger threshold routing, JSON rendering, and redaction/field-name knobs. External config-file loading, rolling files, hot reload, network sinks, and filters remain follow-up backend modules. | -| W-7 | Native bridge (`RTLogbackSink.java`) | **Decision documented; not shipped.** The bridge remains an optional escape hatch in `future/native-bridge.md`. The canonical XDK path should stay pure Ecstasy first so users do not inherit Java Logback configuration/bootstrap behavior by default. | | W-8 | Per-container override convenience | **Resolved: no library helper.** Child containers should override the injectable resource through the host injector API. | | W-9 | Defensive copy of caller-supplied `Object[] arguments` | **Resolved: document, no copy.** Listed here so it can be revisited if async sinks become default-on. | | W-10 | Compiler/tooling lints for log statements | Out of scope for the library; would be an XTC linter feature. Open question 12. | @@ -234,12 +219,12 @@ and what remains deliberate follow-up work. |---|---|---|---| | **1** | Close visible loose ends. No new design choices. | W-1 (multi-marker), W-11 (doc cleanup) | ~1 hour | | **2** | Make `lib_logging` fully comparable as a shippable library. | Tier 1 + W-2 (logger interning, needs a `LoggerRegistry` service since `BasicLogger` is `const`) + W-3 (real `MessageFormatter` with `{}` substitution + trailing-throwable promotion) | ~half a day | -| **3** | Backend/runtime polish. | W-4 runtime fallback naming, W-5 async wrapper, W-6 base Logback-style primitives, W-7 native-bridge decision, W-8 per-container override decision, W-9 defensive-copy decision, W-10 linter deferral | mostly landed; W-4 compiler lowering and W-10 linting remain separate work | +| **3** | Backend/runtime polish. | W-4 runtime fallback naming, W-5 async wrapper, W-6 base Logback-style primitives, W-8 per-container override decision, W-9 defensive-copy decision, W-10 linter deferral | mostly landed; W-4 compiler lowering and W-10 linting remain separate work | **Decision (revised):** Tier 1, Tier 2, and the backend-primitives part of Tier 3 land in this PR for both libraries. The only remaining Tier 3 implementation work is outside the base libraries: compiler-synthesized call-site metadata, linter support, -full config-file loading/destinations, and any optional Java bridge. +and full config-file loading/destinations. The `lib_slogging` parity translation: diff --git a/doc/logging/review-prompt.md b/doc/logging/review-prompt.md deleted file mode 100644 index 68384c37ff..0000000000 --- a/doc/logging/review-prompt.md +++ /dev/null @@ -1,171 +0,0 @@ -# AI review prompt for the logging POC branch - -Use this prompt with any AI coding/review agent to review the entire -`lagergren/logging` branch, with special attention to whether the documentation clearly -teaches the proposed XDK logging design. - -```text -You are reviewing the `lagergren/logging` branch of the XDK repository. - -Your job is to perform a code-and-documentation review, not to rewrite the branch unless -explicitly asked. Treat this as a senior engineering review. Prioritize correctness, -clarity, missing rationale, misleading examples, broken links, implementation/documentation -drift, and places where a skeptical reviewer would get confused. - -Branch intent -------------- -This branch contains two parallel proof-of-concept logging libraries: - -- `lib_logging/`: SLF4J/Logback-shaped Ecstasy logging. This is currently recommended - as the canonical XDK logging facade. -- `lib_slogging/`: Go `log/slog`-shaped Ecstasy logging. This exists as a comparison - POC, not as a proposed permanent second default facade. - -The branch's central design claim is: - -1. XDK code should eventually have one injectable logging facade named `lib_logging`. -2. Application/library code should use `@Inject Logger logger;`. -3. The host/container should own backend policy by injecting or configuring a sink/handler. -4. Structured logging, dynamic backend configuration, JSON/cloud output, async fanout, - redaction, and test capture are requirements for a modern logging design. -5. The branch currently recommends the SLF4J-shaped API because named loggers, `{}` - formatting, markers, MDC, fluent builders, and Logback-style backend configuration are - familiar to the broadest likely audience. - -Review order ------------- -Start from `doc/logging/README.md`. It should be possible to understand the design at a -first-pass level by reading that file alone. Then follow the links in this order: - -1. `doc/logging/lib-logging-vs-lib-slogging.md` -2. `doc/logging/usage/structured-logging.md` -3. `doc/logging/usage/configuration.md` -4. `doc/logging/api-cross-reference.md` -5. `doc/logging/open-questions.md` -6. `lib_logging/README.md` -7. `lib_slogging/README.md` - -After the first pass, read the deep-dive docs as needed: - -- `doc/logging/design/design.md` -- `doc/logging/design/why-slf4j-and-injection.md` -- `doc/logging/design/xdk-alignment.md` -- `doc/logging/cloud-integration.md` -- `doc/logging/usage/injected-logger-example.md` -- `doc/logging/usage/ecstasy-vs-java-examples.md` -- `doc/logging/usage/slf4j-parity.md` -- `doc/logging/usage/slog-parity.md` -- `doc/logging/usage/custom-sinks.md` -- `doc/logging/usage/custom-handlers.md` -- `doc/logging/usage/platform-and-examples-adaptation.md` -- `doc/logging/future/logback-integration.md` -- `doc/logging/future/native-bridge.md` -- `doc/logging/future/lazy-logging.md` -- `doc/logging/future/runtime-implementation-plan.md` - -Also inspect the implementation enough to verify the docs: - -- `lib_logging/src/main/x/logging/` -- `lib_logging/src/test/x/LoggingTest/` -- `lib_slogging/src/main/x/slogging/` -- `lib_slogging/src/test/x/SLoggingTest/` -- `manualTests/src/main/x/TestLogger.x` -- `javatools/src/main/java/org/xvm/runtime/NativeContainer.java` - -Specific questions to answer ----------------------------- -1. Can a first-time reviewer understand from `doc/logging/README.md`: - - why two libraries exist in this branch, - - why only one should survive as the canonical `lib_logging`, - - what is already implemented, - - what is only sketched or future work, - - and why the branch currently recommends the SLF4J-shaped API? - -2. Does the documentation flow logically from overview to API comparison to structured - logging to configuration/backend details? Identify any document that forces a deep - dive before the reader has the basic model. - -3. Are verbose sections placed in the right deep-dive documents, with concise summaries - near the top? Flag any "wall of text" that should be summarized, moved, or split. - -4. Are all public claims about implemented functionality true in the code? - Check especially: - - `MessageFormatter` - - `Logger.logAt(...)` and source metadata - - `JsonLogSink` / `JsonLogSinkOptions` - - `CompositeLogSink`, `HierarchicalLogSink`, `AsyncLogSink` - - slog `JSONHandler`, `HandlerOptions`, `AsyncHandler`, `LoggerContext` - - runtime injection in `NativeContainer.java` - -5. Are proposed future pieces clearly labeled as future work? - Examples: config-file parser, property/env/CLI override merge, file and rolling-file - sinks, provider-specific cloud clients, automatic source capture, compiler-generated - logger names, and a Java Logback native bridge. - -6. Does the comparison between SLF4J/Logback and Go `slog` stay technically fair? - Verify especially: - - slog has attrs/groups/handlers/open levels but no first-class markers, - hierarchical logger-name tree, or message formatter. - - SLF4J/Logback has named loggers, markers, MDC, message templates, and mature - configuration, but a less uniform structured-data model. - - Java ecosystems such as Log4j 2, Flogger, Spring Boot logging, JUL, Commons - Logging, and logstash-logback-encoder are described accurately. - -7. Are Java/Go/Ecstasy examples readable, realistic, and clearly marked as either - implemented POC code or proposed API sketches? - -8. Is the XDK module story clear? - The expected direction is: - - core facade/SPI in the eventual `lib_logging`, - - config loading in a first-class follow-up module such as `lib_logging_config`, - - file destinations in a module such as `lib_logging_file`, - - cloud destinations either in `lib_logging_cloud` or provider-specific modules, - - runtime/compiler polish outside the logging library itself. - -9. Are there broken or stale local links, stale test counts, stale branch-status claims, - or references to old implementation plans that now conflict with the branch? - -10. Do the docs use requirement language consistently? Early requirements should use - MUST/MUST NOT where they are normative. - -Useful local checks -------------------- -Run these if the environment supports them: - -- `git status --short` -- `git log --oneline --decorate -20` -- `git diff --check` -- `./gradlew :xdk:lib-logging:compileXtc :xdk:lib-slogging:compileXtc --console=plain` -- `./gradlew :xdk:lib-logging:test :xdk:lib-slogging:test --console=plain` -- `./gradlew :javatools:compileJava :manualTests:runOne -PtestName=TestLogger --console=plain` - -For local Markdown links, use any equivalent link checker or script. A simple review is -enough if tooling is unavailable. - -Output format -------------- -Return findings first, ordered by severity: - -- `High`: incorrect implementation claim, misleading recommendation, broken core example, - or documentation flow that prevents understanding the proposal. -- `Medium`: important missing explanation, stale status, unclear implemented-vs-future - boundary, or incomplete comparison. -- `Low`: wording, formatting, small link/index issue, minor redundancy. - -For each finding, include: - -- file path and line number, -- the problem, -- why it matters, -- a concrete suggested fix. - -After findings, include: - -- "Reader-flow summary": whether the README -> comparison -> structured logging -> - configuration path works. -- "Implementation/doc drift": any mismatch between docs and code. -- "Suggested next edits": a short prioritized list. -- "Tests/checks run": commands run and results, or explain why they were not run. - -Do not produce a long rewrite unless asked. Focus on review signal. -``` diff --git a/doc/logging/usage/configuration.md b/doc/logging/usage/configuration.md index 7e058985bc..363fefa692 100644 --- a/doc/logging/usage/configuration.md +++ b/doc/logging/usage/configuration.md @@ -34,6 +34,59 @@ and The rest of this document is the detailed prior-art mapping for Logback, Log4j 2, Spring Boot, and Go `slog`. +## Logback-style pieces already implemented in XTC + +Do not read every Logback-style term in this guide as future work. The base +`lib_logging` POC already implements several backend primitives that Java teams +normally associate with Logback rather than SLF4J itself: + +| Logback concept | Implemented XTC type | Status | +|---|---|---| +| Console appender with a fixed human-readable layout | `ConsoleLogSink` | Implemented; default injected sink. | +| Multiple appender refs on one logger | `CompositeLogSink` | Implemented; fans out to multiple delegate sinks. | +| Logger-name hierarchy and longest-prefix level lookup | `HierarchicalLogSink` | Implemented; mutable per-prefix thresholds. | +| Async appender wrapper | `AsyncLogSink` | Implemented; bounded queue, flush/close, drop counter. | +| JSON encoder / logstash-style structured output | `JsonLogSink`, `JsonLogSinkOptions` | Implemented with `lib_json`, source/MDC/marker/exception fields, and redaction. | +| Test capture appender | `MemoryLogSink` | Implemented; equivalent to Logback's `ListAppender`. | + +What is **not** implemented in the base library is the full Logback ecosystem: +configuration-file parsing, filters, pattern layouts, file and rolling-file outputs, +network/cloud destinations, and hot reload. Those belong in follow-up modules layered +on top of the implemented `LogSink` primitives. + +Java Logback: + +```xml + + + + + + + + + 8192 + + +``` + +Same implemented idea in Ecstasy code today: + +```ecstasy +LogSink json = new JsonLogSink(new JsonLogSinkOptions(Info, + ["authorization", "password", "token"])); +LogSink fanout = new CompositeLogSink([ + new ConsoleLogSink(), + new AsyncLogSink(json, capacity=8192) + ]); + +HierarchicalLogSink configured = new HierarchicalLogSink(fanout, Info); +configured.setLevel("com.acme.payments", Debug); + +Logger logger = new BasicLogger("com.acme.payments.Checkout", configured); +logger.debug("charged {}", [amount]); +``` + ## Configuration prior art to account for Logback is the best-known SLF4J backend, but it is not the only operational model that diff --git a/doc/logging/usage/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md index 9a28dfabf5..8ac8a73ebd 100644 --- a/doc/logging/usage/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -9,10 +9,8 @@ The accompanying executable sample lives at > **Status (2026-05).** Runtime injection of `@Inject Logger logger;` is wired in > the interpreter — `NativeContainer.ensureLogger` constructs a `BasicLogger` -> directly. There is no longer a separate `xRTLogger` / `_native.logging.RTLogger` -> service wrapper; collapsing it was needed so per-fiber `MDC` survives injection. -> The JIT injector still needs the equivalent wiring; until it does, `--jit` runs -> won't resolve the injection. +> directly so per-fiber `MDC` survives injection. The JIT injector still needs the +> equivalent wiring; until it does, `--jit` runs won't resolve the injection. ## API at a glance @@ -25,8 +23,7 @@ Logger payments = logger.named("payments"); // per-name child via the API There is **no** `@Inject("payments") Logger logger;` form. Per-name loggers are *derived* from the injected one, the same way SLF4J users write -`LoggerFactory.getLogger(MyClass.class)` once at the top of a class. See -`../future/runtime-implementation-plan.md` Stage 1.4 for why we ruled out wildcard injection. +`LoggerFactory.getLogger(MyClass.class)` once at the top of a class. The examples below `import` the public lib_logging types unqualified by adding type-level imports next to the `package log import …` alias: diff --git a/doc/logging/usage/slf4j-parity.md b/doc/logging/usage/slf4j-parity.md index 1299c88780..9998c20816 100644 --- a/doc/logging/usage/slf4j-parity.md +++ b/doc/logging/usage/slf4j-parity.md @@ -12,9 +12,8 @@ scan this once and know everything they need. | `LoggerFactory.getLogger("com.example.foo")` | `logger.named("com.example.foo")` *(or `LoggerFactory.getLogger("com.example.foo")` when the non-injection factory is wired to a default sink)* | | (no equivalent) | `@Inject Logger logger;` *(injects a single root logger)* | -`@Inject("com.example.foo") Logger logger;` was considered and rejected — see -`../future/runtime-implementation-plan.md` Stage 1.4 for why. SLF4J doesn't have a -parameterized injection annotation either; `LoggerFactory.getLogger(MyClass.class)` +`@Inject("com.example.foo") Logger logger;` was considered and rejected. SLF4J +doesn't have a parameterized injection annotation either; `LoggerFactory.getLogger` is its idiom and `Logger.named(String)` is the direct Ecstasy equivalent. `Logger.named(String)` takes the full logger name. It does not append a suffix to the diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index 1a50dbdf54..7658bf45df 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -108,28 +108,19 @@ public NativeContainer(Runtime runtime, ModuleRepository repository) { // ----- initialization ------------------------------------------------------------------------ private ConstantPool loadNativeTemplates() { - ModuleStructure moduleRoot = f_repository.loadModule(ECSTASY_MODULE); - ModuleStructure moduleTurtle = f_repository.loadModule(TURTLE_MODULE); - ModuleStructure moduleNative = f_repository.loadModule(NATIVE_MODULE); - ModuleStructure moduleLogging = f_repository.loadModule(LOGGING_MODULE); - ModuleStructure moduleSLogging = f_repository.loadModule(SLOGGING_MODULE); + var moduleRoot = f_repository.loadModule(ECSTASY_MODULE); + var moduleTurtle = f_repository.loadModule(TURTLE_MODULE); + var moduleNative = f_repository.loadModule(NATIVE_MODULE); if (moduleRoot == null || moduleTurtle == null || moduleNative == null) { throw new IllegalStateException("Native libraries are missing"); } - m_fLoggingModuleLoaded = moduleLogging != null; - m_fSLoggingModuleLoaded = moduleSLogging != null; - // "root" is a merge of "native" module into the "system" - FileStructure fileRoot = new FileStructure(moduleRoot, true); + var fileRoot = new FileStructure(moduleRoot, true); fileRoot.merge(moduleTurtle, true, false); - if (moduleLogging != null) { - fileRoot.merge(moduleLogging, true, false); - } - if (moduleSLogging != null) { - fileRoot.merge(moduleSLogging, true, false); - } + mergeOptionalModule(fileRoot, LOGGING_MODULE); + mergeOptionalModule(fileRoot, SLOGGING_MODULE); fileRoot.merge(moduleNative, true, false); fileRoot.linkModules(f_repository, true); @@ -389,56 +380,46 @@ private void initResources(ConstantPool pool) { // TODO: This is not intended as permanent code. It is a hack for the POC of // injected SLF4J and slog logging and will likely be implemented differently // and in a more robust manner for the real logging implementation in the XDK. - // When the corresponding POC modules are present, these back - // `@Inject Logger logger;`, `@Inject MDC mdc;`, and - // `@Inject slogging.Logger logger;` on the user side. The two logger resources - // use the same resource name, so getInjectable() must resolve by both name and - // type. - // - // The logger implementations are `const` types, deliberately NOT services: a service - // wrapper around a logger would create a new fiber per call. That would break - // fiber-local context such as logging.MDC and slogging.LoggerContext, because the - // SharedContext tokens registered on the caller's fiber would not be visible from - // inside the wrapper service. - // - // The supplier shape is the same as for any other native-injected resource: one - // `(typeName, resourceName)` registration, no wildcard branch in `getInjectable`. - if (m_fLoggingModuleLoaded) { - ModuleConstant modLogging = pool.ensureModuleConstant(LOGGING_MODULE); - // The injection key uses the user-facing interface type (`Logger`) — that's what - // `@Inject Logger logger;` resolves against. The supplier internally constructs a - // `BasicLogger` (the canonical implementation, a `const`) using that as the - // typeLogger argument to `findConstructor`. - TypeConstant typeLoggerIf = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modLogging, "Logger")); - TypeConstant typeLogger = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modLogging, "BasicLogger")); - m_typeLoggingLogger = typeLoggerIf; - m_typeBasicLogger = typeLogger; - addResourceSupplier(new InjectionKey("logger", typeLoggerIf), - (frame, hOpts) -> ensureLogger(frame, typeLogger, - inferLoggerName(frame, "logger"))); - - TypeConstant typeMDC = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modLogging, "MDC")); - addResourceSupplier(new InjectionKey("mdc", typeMDC), - (frame, hOpts) -> ensureConst(frame, typeMDC)); + registerLoggingResources(pool); + registerSLoggingResources(pool); + } + + private void registerLoggingResources(ConstantPool pool) { + if (!moduleAvailable(LOGGING_MODULE)) { + return; } - if (m_fSLoggingModuleLoaded) { - ModuleConstant modSLogging = pool.ensureModuleConstant(SLOGGING_MODULE); - TypeConstant typeSLogger = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(modSLogging, "Logger")); - addResourceSupplier(new InjectionKey("logger", typeSLogger), - (frame, hOpts) -> { - ConstantPool poolFrame = frame.poolContext(); - TypeConstant typeFrameSLogger = poolFrame.ensureTerminalTypeConstant( - poolFrame.ensureClassConstant( - poolFrame.ensureModuleConstant(SLOGGING_MODULE), - "Logger")); - return ensureConst(frame, typeFrameSLogger); - }); + var typeLogger = typeFor(pool, LOGGING_MODULE, "Logger"); + var typeBasic = typeFor(pool, LOGGING_MODULE, "BasicLogger"); + var typeMDC = typeFor(pool, LOGGING_MODULE, "MDC"); + + m_typeLoggingLogger = typeLogger; + m_typeBasicLogger = typeBasic; + + addResourceSupplier(new InjectionKey("logger", typeLogger), + (frame, hOpts) -> ensureLogger(frame, typeBasic, inferLoggerName(frame, "logger"))); + addResourceSupplier(new InjectionKey("mdc", typeMDC), + (frame, hOpts) -> ensureConst(frame, typeMDC)); + } + + private void registerSLoggingResources(ConstantPool pool) { + if (!moduleAvailable(SLOGGING_MODULE)) { + return; } + + var typeLogger = typeFor(pool, SLOGGING_MODULE, "Logger"); + addResourceSupplier(new InjectionKey("logger", typeLogger), + (frame, hOpts) -> ensureConst(frame, typeFor(frame.poolContext(), + SLOGGING_MODULE, "Logger"))); + } + + private boolean moduleAvailable(String moduleName) { + return f_repository.loadModule(moduleName) != null; + } + + private TypeConstant typeFor(ConstantPool pool, String moduleName, String className) { + var module = pool.ensureModuleConstant(moduleName); + return pool.ensureTerminalTypeConstant(pool.ensureClassConstant(module, className)); } /** @@ -478,17 +459,17 @@ private ObjectHandle ensureLogger(Frame frame, TypeConstant typeLogger, String n */ private String inferLoggerName(Frame frame, String sName) { if ("logger".equals(sName) && frame.f_function != null) { - IdentityConstant idMethod = frame.f_function.getIdentityConstant(); - IdentityConstant idNamespace = idMethod.getNamespace(); + var idMethod = frame.f_function.getIdentityConstant(); + var idNamespace = idMethod.getNamespace(); if (idNamespace != null) { - String sPath = idNamespace.getPathString(); + var sPath = idNamespace.getPathString(); if (sPath != null && !sPath.isEmpty()) { return sPath; } - ModuleConstant idModule = idMethod.getModuleConstant(); + var idModule = idMethod.getModuleConstant(); if (idModule != null) { - String sModule = idModule.getName(); + var sModule = idModule.getName(); if (sModule != null && !sModule.isEmpty()) { return sModule; } @@ -821,19 +802,14 @@ public ConstantPool getConstantPool() { @Override public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, ObjectHandle hOpts) { - for (Map.Entry entry : f_mapResources.entrySet()) { - InjectionKey key = entry.getKey(); + for (var entry : f_mapResources.entrySet()) { + var key = entry.getKey(); if (!key.f_sName.equals(sName)) { continue; } - // Check for equality first, but allow "congruency", "duck type" equality as well - // as sans-Nullable equivalency. This deliberately resolves by (name, type), not just - // name, so `logging.Logger logger` and `slogging.Logger logger` can both use the - // familiar resource name "logger". - TypeConstant typeResource = key.f_type; - if (typeResource.equals(type) || typeResource.isEquivalent(type) - || typeResource.isEquivalent(type.removeNullable())) { + // Resolve by (name, type), not just name, while both POC logger APIs use "logger". + if (matchesType(key.f_type, type)) { return entry.getValue().supply(frame, hOpts); } } @@ -851,10 +827,13 @@ public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, } private boolean isCanonicalLoggingLogger(TypeConstant type) { - TypeConstant typeLogger = m_typeLoggingLogger; - return typeLogger != null && - (typeLogger.equals(type) || typeLogger.isEquivalent(type) - || typeLogger.isEquivalent(type.removeNullable())); + return m_typeLoggingLogger != null && matchesType(m_typeLoggingLogger, type); + } + + private boolean matchesType(TypeConstant typeResource, TypeConstant typeRequest) { + return typeResource.equals(typeRequest) + || typeResource.isEquivalent(typeRequest) + || typeResource.isEquivalent(typeRequest.removeNullable()); } @Override @@ -1048,8 +1027,6 @@ public String toString() { private ModuleStructure m_moduleSystem; private ModuleStructure m_moduleTurtle; private ModuleStructure m_moduleNative; - private boolean m_fLoggingModuleLoaded; - private boolean m_fSLoggingModuleLoaded; /** * Cached canonical logging types. Used to resolve module/class-named injections for diff --git a/javatools_bridge/src/main/x/_native.x b/javatools_bridge/src/main/x/_native.x index 46bebfccc0..4cb020f9b5 100644 --- a/javatools_bridge/src/main/x/_native.x +++ b/javatools_bridge/src/main/x/_native.x @@ -6,9 +6,8 @@ * It is an error for any type or class from this module to be visible to user code. */ module _native.xtclang.org { - package libcolls import collections.xtclang.org; - package libcrypto import crypto.xtclang.org; - package liblogging import logging.xtclang.org; - package libnet import net.xtclang.org; - package libweb import web.xtclang.org; + package libcolls import collections.xtclang.org; + package libcrypto import crypto.xtclang.org; + package libnet import net.xtclang.org; + package libweb import web.xtclang.org; } diff --git a/lib_logging/src/main/x/logging/AsyncLogSink.x b/lib_logging/src/main/x/logging/AsyncLogSink.x index 5a78c9fb5f..fc7c9136dc 100644 --- a/lib_logging/src/main/x/logging/AsyncLogSink.x +++ b/lib_logging/src/main/x/logging/AsyncLogSink.x @@ -5,6 +5,17 @@ * background fiber drains the queue and invokes the delegate sink. The [LogEvent] has * already captured MDC and source metadata before it reaches this sink, so delayed * emission preserves the caller's context. + * + * This is the POC equivalent of Logback's `AsyncAppender`: + * + * + * 8192 + * + * + * + * Ecstasy equivalent: + * + * LogSink sink = new AsyncLogSink(new JsonLogSink(), capacity=8192); */ service AsyncLogSink(LogSink delegate, Int capacity) implements LogSink { diff --git a/lib_logging/src/main/x/logging/CompositeLogSink.x b/lib_logging/src/main/x/logging/CompositeLogSink.x index e3d8f05d5e..338a09612c 100644 --- a/lib_logging/src/main/x/logging/CompositeLogSink.x +++ b/lib_logging/src/main/x/logging/CompositeLogSink.x @@ -4,6 +4,20 @@ * `CompositeLogSink` fans out each enabled event to every delegate sink that accepts * the event's logger/level/primary-marker tuple. This is the Ecstasy equivalent of * attaching multiple Logback appenders to one logger. + * + * Java/Logback equivalent: + * + * + * + * + * + * + * Ecstasy equivalent: + * + * LogSink sink = new CompositeLogSink([ + * new ConsoleLogSink(), + * new JsonLogSink() + * ]); */ const CompositeLogSink(LogSink[] sinks) implements LogSink { diff --git a/lib_logging/src/main/x/logging/ConsoleLogSink.x b/lib_logging/src/main/x/logging/ConsoleLogSink.x index 9cd36306e2..957d317c88 100644 --- a/lib_logging/src/main/x/logging/ConsoleLogSink.x +++ b/lib_logging/src/main/x/logging/ConsoleLogSink.x @@ -72,51 +72,78 @@ const ConsoleLogSink(Level rootLevel) .append(": ") .append(event.message); - if (!event.markers.empty) { - buf.append(" [marker"); - buf.append(event.markers.size == 1 ? "=" : "s="); - Boolean firstMarker = True; - for (Marker m : event.markers) { - if (!firstMarker) { - buf.append(','); - } - firstMarker = False; - buf.append(m.name); - } - buf.append(']'); + appendMarkers(buf, event.markers); + appendMdc(buf, event.mdcSnapshot); + appendKeyValues(buf, event.keyValues); + + console.print(buf.toString()); + + if (Exception e ?= event.exception) { + // TODO(impl): pretty-print stack frames; for now rely on Exception.toString(). + console.print(e.toString()); } + } - if (!event.mdcSnapshot.empty) { - buf.append(" [mdc="); - Boolean firstMdc = True; - for ((String k, String v) : event.mdcSnapshot) { - if (!firstMdc) { - buf.append(','); - } - firstMdc = False; - buf.append(k).append('=').append(v); - } - buf.append(']'); + /** + * Append Logback-style marker text, e.g. `[marker=AUDIT]` or `[markers=AUDIT,SECURITY]`. + */ + private void appendMarkers(StringBuffer buf, Marker[] markers) { + if (markers.empty) { + return; } - if (!event.keyValues.empty) { - buf.append(" {"); - Boolean first = True; - for ((String k, Object v) : event.keyValues) { - if (!first) { - buf.append(", "); - } - first = False; - buf.append(k).append('=').append(v.toString()); - } - buf.append('}'); + buf.append(markers.size == 1 ? " [marker=" : " [markers="); + Boolean first = True; + for (Marker marker : markers) { + first = appendSeparator(buf, first, ","); + buf.append(marker.name); } + buf.append(']'); + } - console.print(buf.toString()); + /** + * Append the MDC snapshot captured before the event reached the sink. + */ + private void appendMdc(StringBuffer buf, Map mdc) { + if (mdc.empty) { + return; + } - if (Exception e ?= event.exception) { - // TODO(impl): pretty-print stack frames; for now rely on Exception.toString(). - console.print(e.toString()); + buf.append(" [mdc="); + Boolean first = True; + for ((String key, String value) : mdc) { + first = appendSeparator(buf, first, ","); + buf.append(key).append('=').append(value); + } + buf.append(']'); + } + + /** + * Append structured key/value pairs in the compact text layout. + */ + private void appendKeyValues(StringBuffer buf, Map values) { + if (values.empty) { + return; + } + + buf.append(" {"); + Boolean first = True; + for ((String key, Object value) : values) { + first = appendSeparator(buf, first, ", "); + buf.append(key).append('=').append(value.toString()); + } + buf.append('}'); + } + + /** + * Append `separator` after the first item in a small rendered list. + * + * @return False, so callers can write `first = appendSeparator(...)`. + */ + private Boolean appendSeparator(StringBuffer buf, Boolean first, String separator) { + if (!first) { + buf.append(separator); } + return False; } } diff --git a/lib_logging/src/main/x/logging/HierarchicalLogSink.x b/lib_logging/src/main/x/logging/HierarchicalLogSink.x index b7bb2a6b7c..16a8379b55 100644 --- a/lib_logging/src/main/x/logging/HierarchicalLogSink.x +++ b/lib_logging/src/main/x/logging/HierarchicalLogSink.x @@ -4,6 +4,17 @@ * The sink keeps a mutable map of logger-name prefixes to levels. The longest matching * prefix wins; when no prefix matches, [rootLevel] applies. Events that pass the * threshold are forwarded to [delegate]. + * + * This is backend behavior, not pure SLF4J facade behavior. It corresponds to Logback's + * configured logger tree: + * + * + * + * + * Ecstasy equivalent: + * + * HierarchicalLogSink sink = new HierarchicalLogSink(new ConsoleLogSink(), Info); + * sink.setLevel("com.acme.payments", Debug); */ service HierarchicalLogSink(LogSink delegate, Level rootLevel) implements LogSink { diff --git a/lib_logging/src/main/x/logging/JsonLogSink.x b/lib_logging/src/main/x/logging/JsonLogSink.x index 707fa35974..ec22efb057 100644 --- a/lib_logging/src/main/x/logging/JsonLogSink.x +++ b/lib_logging/src/main/x/logging/JsonLogSink.x @@ -9,6 +9,16 @@ import json.Printer; * It renders through `lib_json`, preserves MDC, markers, structured key/value pairs, * exceptions, and explicit source metadata, and supports key-based redaction through * [JsonLogSinkOptions]. + * + * This is backend/encoder behavior, not part of SLF4J itself. The Java comparison point + * is Logback with a JSON encoder such as `logstash-logback-encoder`: + * + * + * + * Ecstasy equivalent: + * + * LogSink sink = new JsonLogSink(new JsonLogSinkOptions(Info, + * ["authorization", "password"])); */ const JsonLogSink(JsonLogSinkOptions options) implements LogSink { diff --git a/lib_logging/src/main/x/logging/LogSink.x b/lib_logging/src/main/x/logging/LogSink.x index bf8611cb0d..2788158f83 100644 --- a/lib_logging/src/main/x/logging/LogSink.x +++ b/lib_logging/src/main/x/logging/LogSink.x @@ -1,12 +1,11 @@ /** - * Corresponds, conceptually, to two things in the SLF4J/Logback world: - * - `org.slf4j.spi.SLF4JServiceProvider` — the binding/provider boundary you implement - * to plug a backend into SLF4J. - * - `ch.qos.logback.core.Appender` — the per-destination output sink in Logback. + * Corresponds conceptually to a Logback `Appender`: a per-destination backend with its + * own level/filter decision. This is not part of pure SLF4J; it is the backend boundary + * that a real SLF4J deployment would normally get from Logback, Log4j 2, or another + * implementation. * - * `LogSink` is more like the Logback `Appender`: a single emission target with its own - * level filter. Mapping multiple `LogSink`s onto one logger (Logback's "appender attached - * to logger" model) is the job of [CompositeLogSink]. + * Mapping multiple `LogSink`s onto one logger (Logback's "appender attached to logger" + * model) is the job of [CompositeLogSink]. * * The Service-Provider Interface for logging backends. A `LogSink` is the only thing a * `Logger` ever talks to; everything else (level checks, message formatting, marker @@ -18,9 +17,8 @@ * Anything _below_ this interface is replaceable: `ConsoleLogSink` for the default * platform-controlled console output, `MemoryLogSink` in tests, [JsonLogSink] for * structured JSON-Lines output, [CompositeLogSink] and [HierarchicalLogSink] for - * Logback-style backend policy, a future file/network sink, or an optional native sink - * wrapping `slf4j`+`logback` via the JIT bridge — all of those are interchangeable - * behind this interface. + * Logback-style backend policy, or a future file/network/cloud sink. All of those are + * interchangeable behind this interface. * * # Implementing a custom sink * diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index 73a2248b83..bee84946f8 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -66,20 +66,8 @@ const TextHandler(HandlerOptions options, String groupPrefix) .append("msg=") .append(quote(record.message)); - for (Attr a : record.attrs) { - buf.append(' '); - renderAttr(buf, groupPrefix, a); - } - - if (options.includeSource) { - if (String file ?= record.sourceFile) { - buf.append(" source=") - .append(file); - if (record.sourceLine >= 0) { - buf.append(':').append(record.sourceLine); - } - } - } + appendAttrs(buf, groupPrefix, record.attrs); + appendSource(buf, record); console.print(buf.toString()); @@ -107,20 +95,56 @@ const TextHandler(HandlerOptions options, String groupPrefix) private void renderAttr(StringBuffer buf, String prefix, Attr a) { String key = prefix == "" ? a.key : $"{prefix}.{a.key}"; if (a.value.is(Attr[])) { - Boolean first = True; - for (Attr child : a.value.as(Attr[])) { - if (!first) { - buf.append(' '); - } - first = False; - renderAttr(buf, key, child); - } + appendNestedAttrs(buf, key, a.value.as(Attr[])); } else { buf.append(key).append('=') .append(options.redacts(a.key) ? options.redaction : a.value.toString()); } } + /** + * Append top-level attrs. Each attr starts with a leading space because the base + * record fields have already been rendered. + */ + private void appendAttrs(StringBuffer buf, String prefix, Attr[] attrs) { + for (Attr attr : attrs) { + buf.append(' '); + renderAttr(buf, prefix, attr); + } + } + + /** + * Append children of an attr group. The first child continues in the current + * position; later children are separated with spaces. + */ + private void appendNestedAttrs(StringBuffer buf, String prefix, Attr[] attrs) { + Boolean first = True; + for (Attr attr : attrs) { + if (!first) { + buf.append(' '); + } + first = False; + renderAttr(buf, prefix, attr); + } + } + + /** + * Append source metadata when caller-supplied source capture is enabled. + */ + private void appendSource(StringBuffer buf, Record record) { + if (!options.includeSource) { + return; + } + + if (String file ?= record.sourceFile) { + buf.append(" source=") + .append(file); + if (record.sourceLine >= 0) { + buf.append(':').append(record.sourceLine); + } + } + } + /** * POC string quoting. Production text output should escape quotes, newlines, and * separators; that belongs with the production handler, not the API sketch. diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x index f7c7b10535..3259640f48 100644 --- a/manualTests/src/main/x/TestLogger.x +++ b/manualTests/src/main/x/TestLogger.x @@ -96,9 +96,9 @@ module TestLogger { * class). The derived logger shares this logger's sink, so all configuration applied * to the default logger flows through to its descendants. * - * Bare-essentials demo target (`doc/logging/future/runtime-implementation-plan.md`): the - * message must print as `hello world` (formatted by `MessageFormatter`), not - * `hello {}` (raw). The logger-name column on the resulting line should read `Demo`. + * Bare-essentials demo target: the message must print as `hello world` (formatted by + * `MessageFormatter`), not `hello {}` (raw). The logger-name column on the resulting + * line should read `Demo`. */ void runInjectedByName() { @Inject Logger logger; diff --git a/slf4j_full_guide.md b/slf4j_full_guide.md deleted file mode 100644 index d2812bcb3a..0000000000 --- a/slf4j_full_guide.md +++ /dev/null @@ -1,377 +0,0 @@ -# SLF4J 2.x Provider for a New Language (Full Guide) - -This document is a **complete, copy‑paste ready** guide for implementing a minimal SLF4J 2.x backend (provider) that can initially delegate to Java, while keeping a clean path to a native runtime later. - ---- - -## Architecture - -**SLF4J (Java side)** → **Adapter (your provider)** → **RuntimeLogSink (your abstraction)** → **Backend (JUL now, native later)** - -Key idea: **SLF4J is just an edge adapter**, not your core logging model. - ---- - -## Project Layout - -``` -mylang-slf4j-provider/ - build.gradle.kts - settings.gradle.kts - src/main/java/mylang/slf4j/ - MyLangServiceProvider.java - MyLangLoggerFactory.java - MyLangLogger.java - RuntimeLogSink.java - JulRuntimeLogSink.java - LogLevel.java - LogEvent.java - src/main/resources/ - META-INF/services/org.slf4j.spi.SLF4JServiceProvider -``` - ---- - -## Gradle Build - -```kotlin -plugins { - `java-library` -} - -group = "mylang" -version = "0.1.0" - -repositories { - mavenCentral() -} - -dependencies { - api("org.slf4j:slf4j-api:2.0.17") -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} -``` - ---- - -## Service Provider - -```java -package mylang.slf4j; - -import org.slf4j.ILoggerFactory; -import org.slf4j.IMarkerFactory; -import org.slf4j.helpers.BasicMarkerFactory; -import org.slf4j.helpers.BasicMDCAdapter; -import org.slf4j.spi.MDCAdapter; -import org.slf4j.spi.SLF4JServiceProvider; - -public final class MyLangServiceProvider implements SLF4JServiceProvider { - public static final String REQUESTED_API_VERSION = "2.0.99"; - - private ILoggerFactory loggerFactory; - private IMarkerFactory markerFactory; - private MDCAdapter mdcAdapter; - - @Override - public void initialize() { - RuntimeLogSink sink = new JulRuntimeLogSink(); - this.loggerFactory = new MyLangLoggerFactory(sink); - this.markerFactory = new BasicMarkerFactory(); - this.mdcAdapter = new BasicMDCAdapter(); - } - - @Override - public ILoggerFactory getLoggerFactory() { - return loggerFactory; - } - - @Override - public IMarkerFactory getMarkerFactory() { - return markerFactory; - } - - @Override - public MDCAdapter getMDCAdapter() { - return mdcAdapter; - } - - @Override - public String getRequestedApiVersion() { - return REQUESTED_API_VERSION; - } -} -``` - ---- - -## Logger Factory - -```java -package mylang.slf4j; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import org.slf4j.ILoggerFactory; -import org.slf4j.Logger; - -public final class MyLangLoggerFactory implements ILoggerFactory { - private final RuntimeLogSink sink; - private final ConcurrentMap cache = new ConcurrentHashMap<>(); - - public MyLangLoggerFactory(RuntimeLogSink sink) { - this.sink = sink; - } - - @Override - public Logger getLogger(String name) { - return cache.computeIfAbsent(name, n -> new MyLangLogger(n, sink)); - } -} -``` - ---- - -## Runtime Abstraction - -### RuntimeLogSink - -```java -package mylang.slf4j; - -public interface RuntimeLogSink { - boolean isEnabled(String loggerName, LogLevel level); - void log(LogEvent event); -} -``` - -### LogLevel - -```java -package mylang.slf4j; - -public enum LogLevel { - TRACE, - DEBUG, - INFO, - WARN, - ERROR -} -``` - -### LogEvent - -```java -package mylang.slf4j; - -public record LogEvent( - String loggerName, - LogLevel level, - String message, - Throwable throwable, - String threadName, - long timestampMillis -) {} -``` - ---- - -## Initial Backend (JUL) - -```java -package mylang.slf4j; - -import java.util.logging.Level; -import java.util.logging.Logger; - -public final class JulRuntimeLogSink implements RuntimeLogSink { - - @Override - public boolean isEnabled(String loggerName, LogLevel level) { - Logger logger = Logger.getLogger(loggerName); - return logger.isLoggable(toJul(level)); - } - - @Override - public void log(LogEvent event) { - Logger logger = Logger.getLogger(event.loggerName()); - logger.log( - toJul(event.level()), - event.message(), - event.throwable() - ); - } - - private static Level toJul(LogLevel level) { - return switch (level) { - case TRACE -> Level.FINER; - case DEBUG -> Level.FINE; - case INFO -> Level.INFO; - case WARN -> Level.WARNING; - case ERROR -> Level.SEVERE; - }; - } -} -``` - ---- - -## Logger Implementation - -```java -package mylang.slf4j; - -import org.slf4j.Marker; -import org.slf4j.event.Level; -import org.slf4j.helpers.AbstractLogger; -import org.slf4j.helpers.MessageFormatter; - -public final class MyLangLogger extends AbstractLogger { - - private final RuntimeLogSink sink; - - public MyLangLogger(String name, RuntimeLogSink sink) { - this.name = name; - this.sink = sink; - } - - @Override - public boolean isTraceEnabled() { return sink.isEnabled(name, LogLevel.TRACE); } - - @Override - public boolean isDebugEnabled() { return sink.isEnabled(name, LogLevel.DEBUG); } - - @Override - public boolean isInfoEnabled() { return sink.isEnabled(name, LogLevel.INFO); } - - @Override - public boolean isWarnEnabled() { return sink.isEnabled(name, LogLevel.WARN); } - - @Override - public boolean isErrorEnabled() { return sink.isEnabled(name, LogLevel.ERROR); } - - @Override public boolean isTraceEnabled(Marker m) { return isTraceEnabled(); } - @Override public boolean isDebugEnabled(Marker m) { return isDebugEnabled(); } - @Override public boolean isInfoEnabled(Marker m) { return isInfoEnabled(); } - @Override public boolean isWarnEnabled(Marker m) { return isWarnEnabled(); } - @Override public boolean isErrorEnabled(Marker m) { return isErrorEnabled(); } - - @Override - protected String getFullyQualifiedCallerName() { - return MyLangLogger.class.getName(); - } - - @Override - protected void handleNormalizedLoggingCall( - Level level, - Marker marker, - String messagePattern, - Object[] arguments, - Throwable throwable - ) { - String msg = MessageFormatter - .arrayFormat(messagePattern, arguments, throwable) - .getMessage(); - - sink.log(new LogEvent( - name, - map(level), - msg, - throwable, - Thread.currentThread().getName(), - System.currentTimeMillis() - )); - } - - private static LogLevel map(Level level) { - return switch (level) { - case TRACE -> LogLevel.TRACE; - case DEBUG -> LogLevel.DEBUG; - case INFO -> LogLevel.INFO; - case WARN -> LogLevel.WARN; - case ERROR -> LogLevel.ERROR; - }; - } -} -``` - ---- - -## Service Registration - -File: - -``` -META-INF/services/org.slf4j.spi.SLF4JServiceProvider -``` - -Content: - -``` -mylang.slf4j.MyLangServiceProvider -``` - ---- - -## Example Usage - -```java -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class Demo { - private static final Logger log = LoggerFactory.getLogger(Demo.class); - - public static void main(String[] args) { - log.info("Hello {}", "world"); - } -} -``` - ---- - -## Design Notes - -- Use `AbstractLogger` → avoids implementing 40+ overloads manually -- Use `MessageFormatter` → correct `{}` formatting -- Ignore markers initially -- Use `BasicMDCAdapter` unless you need distributed tracing - ---- - -## Future Evolution - -Replace: - -``` -JulRuntimeLogSink -``` - -with: - -``` -NativeRuntimeLogSink -``` - -No SLF4J changes required. - ---- - -## Core Principle - -**Do NOT design your runtime around SLF4J.** - -Instead: - -- Define your own logging model -- Treat SLF4J as an adapter layer -- Keep full control over your logging semantics - ---- - -End of document. From 675985bcb7fc3a3cba069d557a3ec7407e49a6af Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 11:42:43 +0200 Subject: [PATCH 20/28] Add lazy logging to logging POCs --- doc/logging/README.md | 24 +- doc/logging/api-cross-reference.md | 6 +- doc/logging/design/design.md | 8 +- doc/logging/future/lazy-logging.md | 248 ------------------ doc/logging/lib-logging-vs-lib-slogging.md | 14 +- doc/logging/usage/configuration.md | 2 +- doc/logging/usage/ecstasy-vs-java-examples.md | 32 ++- doc/logging/usage/injected-logger-example.md | 8 +- doc/logging/usage/lazy-logging.md | 173 ++++++++++++ doc/logging/usage/slog-parity.md | 7 + lib_logging/README.md | 10 +- lib_logging/src/main/x/logging.x | 20 ++ .../src/main/x/logging/BasicEventBuilder.x | 123 +++++++-- lib_logging/src/main/x/logging/BasicLogger.x | 72 ++++- lib_logging/src/main/x/logging/Logger.x | 68 ++++- .../src/main/x/logging/LoggingEventBuilder.x | 29 +- .../src/test/x/LoggingTest/LazyLoggingTest.x | 92 +++++++ lib_slogging/README.md | 10 +- lib_slogging/src/main/x/slogging.x | 22 +- lib_slogging/src/main/x/slogging/Attr.x | 48 ++++ .../src/main/x/slogging/BoundHandler.x | 5 +- lib_slogging/src/main/x/slogging/LazyValue.x | 14 + lib_slogging/src/main/x/slogging/Logger.x | 77 +++++- .../src/test/x/SLoggingTest/LazyCounter.x | 12 + .../src/test/x/SLoggingTest/LazyLoggingTest.x | 78 ++++++ 25 files changed, 876 insertions(+), 326 deletions(-) delete mode 100644 doc/logging/future/lazy-logging.md create mode 100644 doc/logging/usage/lazy-logging.md create mode 100644 lib_logging/src/test/x/LoggingTest/LazyLoggingTest.x create mode 100644 lib_slogging/src/main/x/slogging/LazyValue.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/LazyCounter.x create mode 100644 lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x diff --git a/doc/logging/README.md b/doc/logging/README.md index b62c9fa2f9..8f0ccd6ddf 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -58,6 +58,9 @@ The accepted XDK logging API **MUST** satisfy these requirements: backend policy; library code MUST NOT choose global process logging. - **Structured events:** events MUST carry first-class key/value fields, context fields, exceptions, markers/categories where applicable, and JSON/cloud output without parsing messages. +- **Lazy disabled calls:** expensive message, argument, and structured-value construction + MUST be deferrable behind the level/marker check without requiring verbose caller-side + `if (logger.debugEnabled)` guards for every one-line log statement. - **Dynamically pluggable backends:** sinks/handlers MUST be swappable, composable, wrappable with async queues, and replaced by a host-controlled reload service. - **Logback-equivalent operations:** the backend MUST support root level, per-logger/category overrides, @@ -77,8 +80,8 @@ to JSON/cloud sinks without parsing message text. | Library | Prior art | Current proof | |---|---|---| -| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 66 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | -| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 37 focused XTC test methods, injected/manual coverage, async handler, handler options, and JSON/redaction support. | +| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 70 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | +| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 41 focused XTC test methods, injected/manual coverage, async handler, lazy attrs, handler options, and JSON/redaction support. | ## Recommendation @@ -99,8 +102,9 @@ Start here, then follow only the path that matches your review: 1. **First-pass design:** this README. 2. **API choice:** [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md). 3. **Structured logging requirement:** [`usage/structured-logging.md`](usage/structured-logging.md). -4. **Configuration/backend story:** [`usage/configuration.md`](usage/configuration.md). -5. **Exact API mapping:** [`api-cross-reference.md`](api-cross-reference.md). +4. **Lazy logging requirement:** [`usage/lazy-logging.md`](usage/lazy-logging.md). +5. **Configuration/backend story:** [`usage/configuration.md`](usage/configuration.md). +6. **Exact API mapping:** [`api-cross-reference.md`](api-cross-reference.md). The rest of the tree is organized by reader: @@ -138,10 +142,10 @@ The rest of the tree is organized by reader: | [`usage/custom-sinks.md`](usage/custom-sinks.md) | How to write a custom `LogSink`, with worked examples. | | [`usage/custom-handlers.md`](usage/custom-handlers.md) | How to write a custom slog `Handler`, with worked examples. | | [`usage/structured-logging.md`](usage/structured-logging.md) | Structured logging dive: key/value pairs, JSON output, fluent builder. | +| [`usage/lazy-logging.md`](usage/lazy-logging.md) | Lazy message/value construction: Kotlin blocks, Java suppliers, and the Ecstasy API. | | [`usage/configuration.md`](usage/configuration.md) | Logback XML vs proposed Ecstasy JSON configuration, dynamic sink/handler reload, and equivalence mapping. | | **Follow-up backend/compiler work** | | | [`future/logback-integration.md`](future/logback-integration.md) | Configuration-driven Logback-style backend on top of the shipped primitives. | -| [`future/lazy-logging.md`](future/lazy-logging.md) | Kotlin-style lambda emission (`logger.info { "..." }`) — exploration. | ## Where the actual code lives @@ -175,7 +179,7 @@ lib_logging/ SLF4J-shaped library │ ├── AsyncLogSink.x bounded async wrapper (service) │ ├── NoopLogSink.x drops every event (const) │ └── MemoryLogSink.x test-helper, captures events (service) - └── test/x/LoggingTest/ 66 focused XTC test methods + └── test/x/LoggingTest/ 70 focused XTC test methods lib_slogging/ slog-shaped sibling library ├── build.gradle.kts @@ -196,7 +200,7 @@ lib_slogging/ slog-shaped sibling library │ ├── AsyncHandler.x bounded async wrapper (service) │ ├── NopHandler.x drops every record (const) │ └── MemoryHandler.x test-helper (service) - └── test/x/SLoggingTest/ 37 focused XTC test methods + └── test/x/SLoggingTest/ 41 focused XTC test methods ``` ## Status @@ -209,9 +213,9 @@ from the caller namespace when the compiler only supplies the default field name automatic compiler call-site capture remains the next compiler/runtime polish step. Tier 3 backend primitives have landed in the base libraries. A full -configuration-file loader, rolling-file/network destinations, and an optional Java -Logback bridge are deliberately not shipped in this branch; the docs explain where -those belong if the canonical `lib_logging` API is accepted. +configuration-file loader and rolling-file/network destinations are deliberately not +shipped in this branch; the docs explain where those pure-XTC backend modules belong if +the canonical `lib_logging` API is accepted. ## Backend boundary diff --git a/doc/logging/api-cross-reference.md b/doc/logging/api-cross-reference.md index 3e7537a761..dd4709abcc 100644 --- a/doc/logging/api-cross-reference.md +++ b/doc/logging/api-cross-reference.md @@ -33,7 +33,7 @@ Primary references: | [`Marker`](../../lib_logging/src/main/x/logging/Marker.x), [`BasicMarker`](../../lib_logging/src/main/x/logging/BasicMarker.x), [`MarkerFactory`](../../lib_logging/src/main/x/logging/MarkerFactory.x) | SLF4J [`Marker`](https://www.slf4j.org/api/org/slf4j/Marker.html), [`MarkerFactory`](https://www.slf4j.org/api/org/slf4j/MarkerFactory.html) | Named event tags, child-marker containment, detached vs interned markers, multiple markers through the fluent API. | `Marker` also extends XDK `Freezable`, `Stringable`, and `Hashable` so markers can ride on `const LogEvent`s and cross service boundaries. | | [`MDC`](../../lib_logging/src/main/x/logging/MDC.x) | SLF4J [`MDC`](https://www.slf4j.org/api/org/slf4j/MDC.html) | Request-scoped string map captured on each event. | Acquired by `@Inject MDC mdc;` and backed by `SharedContext` for per-fiber semantics instead of Java static thread-local access. | | [`MessageFormatter`](../../lib_logging/src/main/x/logging/MessageFormatter.x) | SLF4J [`MessageFormatter`](https://www.slf4j.org/api/org/slf4j/helpers/MessageFormatter.html) | `{}` substitution, escape handling, and trailing-exception promotion. | Returns `(String, Exception?)`; an explicit `cause=` on the logger call wins over a promoted trailing exception. | -| [`LoggingEventBuilder`](../../lib_logging/src/main/x/logging/LoggingEventBuilder.x), [`BasicEventBuilder`](../../lib_logging/src/main/x/logging/BasicEventBuilder.x) | SLF4J [`LoggingEventBuilder`](https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html) | Fluent `setMessage`, `addArgument`, `addMarker`, `setCause`, `addKeyValue`, `log`. | Supplier/lazy overloads are not implemented yet. The level check runs at `log(...)` so marker-aware sinks can participate. | +| [`LoggingEventBuilder`](../../lib_logging/src/main/x/logging/LoggingEventBuilder.x), [`BasicEventBuilder`](../../lib_logging/src/main/x/logging/BasicEventBuilder.x) | SLF4J [`LoggingEventBuilder`](https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html) | Fluent `setMessage`, `addArgument`, `addMarker`, `setCause`, `addKeyValue`, `log`, plus lazy message/value suppliers. | Ecstasy keeps lazy value methods explicit (`addLazyArgument`, `addLazyKeyValue`) because `function Object()` is also an `Object`, unlike Java's distinct `Supplier` overload. The level check runs before suppliers are invoked. | | [`LogEvent`](../../lib_logging/src/main/x/logging/LogEvent.x) | SLF4J [`LoggingEvent`](https://www.slf4j.org/api/org/slf4j/event/LoggingEvent.html), Logback `ILoggingEvent` | Immutable event payload carrying logger name, level, message, markers, cause, arguments, MDC, source metadata, and key/value pairs. | Ecstasy uses a `const`, with `Marker[] markers` plus `marker` convenience accessor and a `Map` for structured KV pairs. `sourceFile` / `sourceLine` are explicit today and ready for compiler call-site capture later. | | [`LogSink`](../../lib_logging/src/main/x/logging/LogSink.x) | Logback appender concept | Backend SPI: cheap enabled check plus event emission. | One interface allows both `const` forwarders and `service` stateful sinks; the `const`/`service` choice is documented in [`design/design.md`](design/design.md). | | [`ConsoleLogSink`](../../lib_logging/src/main/x/logging/ConsoleLogSink.x), [`NoopLogSink`](../../lib_logging/src/main/x/logging/NoopLogSink.x), [`MemoryLogSink`](../../lib_logging/src/main/x/logging/MemoryLogSink.x) | `slf4j-simple`, `slf4j-nop`, Logback `ListAppender`-style test capture | Minimal default output, silent output, and test capture. | Shipped as tiny Ecstasy sinks so the base library is useful without a Logback-style backend. | @@ -46,11 +46,11 @@ Primary references: | Ecstasy implementation | Prior-art API | Same idea | Ecstasy difference | |---|---|---|---| -| [`Logger`](../../lib_slogging/src/main/x/slogging/Logger.x) | Go [`slog.Logger`](https://pkg.go.dev/log/slog#Logger) | A logger holds a handler; per-level methods create records and forward them. `with` / `withGroup` derive handlers. | Ecstasy `Logger` is a `const` and is injectable via `@Inject slogging.Logger logger;`. It omits Go's `context.Context` parameter from per-call APIs. | +| [`Logger`](../../lib_slogging/src/main/x/slogging/Logger.x) | Go [`slog.Logger`](https://pkg.go.dev/log/slog#Logger) | A logger holds a handler; per-level methods create records and forward them. `with` / `withGroup` derive handlers. | Ecstasy `Logger` is a `const` and is injectable via `@Inject slogging.Logger logger;`. It omits Go's `context.Context` parameter from per-call APIs and adds lazy message suppliers. | | [`LoggerContext`](../../lib_slogging/src/main/x/slogging/LoggerContext.x) | Go [`context.Context`](https://pkg.go.dev/context) as used by `log/slog` | Optional request-scoped propagation for the active logger. | Uses Ecstasy `SharedContext` instead of threading `context.Context` through every logging call. Explicit logger passing remains the default recommendation. | | [`Handler`](../../lib_slogging/src/main/x/slogging/Handler.x), [`BoundHandler`](../../lib_slogging/src/main/x/slogging/BoundHandler.x) | Go [`slog.Handler`](https://pkg.go.dev/log/slog#Handler) | `enabled`, `handle`, `withAttrs`, `withGroup`; derivation lets handlers pre-resolve context. | Ecstasy drops the `context.Context` parameter and `error` return for now; `BoundHandler` supplies the default derivation semantics for simple handlers. | | [`Record`](../../lib_slogging/src/main/x/slogging/Record.x) | Go [`slog.Record`](https://pkg.go.dev/log/slog#Record) | One immutable record carries time, level, message, source, and attrs. | Adds an explicit `Exception?` slot because Ecstasy exceptions are first-class and tests can assert on them directly. | -| [`Attr`](../../lib_slogging/src/main/x/slogging/Attr.x) | Go [`slog.Attr`](https://pkg.go.dev/log/slog#Attr), [`slog.Group`](https://pkg.go.dev/log/slog#Group) | All structured data is key/value attrs; groups are nested attrs. | Uses `Object` values instead of Go's `slog.Value` kind union. Handlers inspect value types directly. | +| [`Attr`](../../lib_slogging/src/main/x/slogging/Attr.x), [`LazyValue`](../../lib_slogging/src/main/x/slogging/LazyValue.x) | Go [`slog.Attr`](https://pkg.go.dev/log/slog#Attr), [`slog.Group`](https://pkg.go.dev/log/slog#Group), `LogValuer` | All structured data is key/value attrs; groups are nested attrs; lazy values resolve after enablement. | Uses `Object` values instead of Go's `slog.Value` kind union. `Attr.lazy("k", () -> v)` is the POC's compact `LogValuer` equivalent. | | [`Level`](../../lib_slogging/src/main/x/slogging/Level.x) | Go [`slog.Level`](https://pkg.go.dev/log/slog#Level) | Open integer severity line with canonical `Debug`, `Info`, `Warn`, `Error`. | Stores a display `label` alongside severity for simple rendering. | | [`HandlerOptions`](../../lib_slogging/src/main/x/slogging/HandlerOptions.x) | Go [`HandlerOptions`](https://pkg.go.dev/log/slog#HandlerOptions) | Root level, source inclusion, field names, and replacement policy live beside handlers. | This POC models the production knobs it needs now: threshold, redacted keys, redaction token, source inclusion, and JSON/text field names. | | [`TextHandler`](../../lib_slogging/src/main/x/slogging/TextHandler.x) | Go [`TextHandler`](https://pkg.go.dev/log/slog#TextHandler) | Human-readable `key=value` output and group prefixing. | Writes through `@Inject Console` and honors `HandlerOptions` threshold, source inclusion, and redaction. | diff --git a/doc/logging/design/design.md b/doc/logging/design/design.md index 561c7f91dc..194e12bc57 100644 --- a/doc/logging/design/design.md +++ b/doc/logging/design/design.md @@ -100,9 +100,11 @@ boundary; everything below is replaceable. ### `LoggingEventBuilder` SLF4J 2.x fluent builder: `setMessage / addArgument / addMarker / setCause / -addKeyValue / log`. The `at*()` methods on `Logger` short-circuit to a no-op builder -when the level is disabled, so callers don't pay for argument construction that won't -be used. +addKeyValue / log`, plus lazy `setMessage`, `log`, `addLazyArgument`, and +`addLazyKeyValue` forms. The builder checks the sink at final `log(...)` time after +markers are attached. If disabled, it does not format, snapshot MDC, allocate a +`LogEvent`, or invoke lazy suppliers. Eager argument expressions still run before the +method call, exactly as in Java; use the explicit lazy methods for expensive values. ## Default implementations diff --git a/doc/logging/future/lazy-logging.md b/doc/logging/future/lazy-logging.md deleted file mode 100644 index 3fee625d11..0000000000 --- a/doc/logging/future/lazy-logging.md +++ /dev/null @@ -1,248 +0,0 @@ -# Lazy logging exploration - -This document explores how to give `lib_logging` users the same ergonomic property the -Kotlin `kotlin-logging` community loves: - -```kotlin -log.info { "expensive: ${serializer.dump(thing)}" } -``` - -The lambda body is evaluated *only* if the level is enabled. Caller code is one line and -zero-cost when the level is off. - -## Why this matters - -Three things together motivate caring about lazy logging: - -1. **Argument construction is the dominant cost of a disabled log call.** SLF4J's `{}` - substitution defers the *formatting* but not the *argument materialization* — if you - write `logger.debug("size: {}", expensiveSerialize(thing))`, the - `expensiveSerialize(thing)` call still runs even when DEBUG is off. The level check - only protects the `MessageFormatter` work, not the caller's expression evaluation. -2. **The traditional escape hatch is verbose.** `if (logger.debugEnabled) { logger.debug(...) }` - works, but doubles the line count of every expensive log site, and people skip the - guard until a profiler tells them to add it back. -3. **Modern logging libraries have figured this out.** Kotlin, JUL, SLF4J 2.x, Scala, - Rust, C++ spdlog — they all offer some lazy-by-default emission. We should too. - -## What we already have for free - -`lib_logging` already eliminates two of the three sources of disabled-call overhead: - -- **Deferred formatting.** `MessageFormatter` only runs after the `sink.isEnabled` check. - When the level is off, no string is built. -- **Builder short-circuits.** `logger.atDebug()` will, when fully implemented, return a - no-op builder when DEBUG is disabled, so the chain - `logger.atDebug().addArgument(x).addKeyValue("k", v).log("...")` does no work. - -The remaining gap is **caller-side expression evaluation** of `arguments`/values. That's -exactly the gap the Kotlin lambda form closes. - -## Industry survey — how other languages address this - -| Ecosystem | Mechanism | Notes | -|---|---|---| -| **Java SLF4J 2.x** | `LoggingEventBuilder.log(Supplier)` and `addArgument(Supplier)` | The builder evaluates suppliers lazily. Older `Logger` overloads do not. | -| **Java JUL** | `Logger.log(Level, Supplier)` since Java 8 | Lazy variant added late but in stdlib. | -| **kotlin-logging** | Inline functions taking `() -> Any?` lambda, e.g. `logger.info { msg }` | The de-facto Kotlin idiom. Inlined at the call site so no allocation. | -| **Scala scala-logging** | Compile-time macros — the call expands at compile time to wrap the message in a `if (logger.isDebugEnabled)` guard | Zero overhead when disabled, no runtime check beyond the level read. | -| **Rust `log`** | Macros (`debug!`, `info!`) | Same idea: macro expands to the if-enabled guard around format args. Compile-time, not runtime. | -| **Go `log/slog`** | `slog.LogValuer` interface lets the value type defer its own resolution | Plus handler-side `Enabled(level)` check before any attribute is materialized. | -| **C++ spdlog / glog** | Macros (`SPDLOG_DEBUG`, `LOG(INFO)`) — expand to a guarded statement | Pre-processor lazy. | -| **Python stdlib `logging`** | `%s` placeholder + `*args` gets deferred formatting; for full arg laziness people guard on `isEnabledFor(...)` | No first-class lambda support; the convention is good enough for most cases. | -| **Erlang/Elixir Logger** | Reports as a *function* `Logger.debug(fn -> "..." end)` | Lambda-based laziness baked into the API. | - -The pattern is universal: every modern logging library has a way to defer message -construction. Some use compile-time macros, some use runtime closures, some use type- -based deferral. **The user expectation is that this works.** Anyone porting a Kotlin or -SLF4J 2.x service over will look for it first thing. - -## Three options for `lib_logging` - -We have three plausible paths. They are not mutually exclusive — we can ship one now and -add others later. Listed in order of ease. - -### Option 1 — `Supplier` overloads on `Logger` - -Add lambda-taking variants of every level method. The signature uses Ecstasy's -function-typed parameters: - -```ecstasy -interface Logger { - // Existing: - void info(String message, - Object[] arguments = [], - Exception? cause = Null, - Marker? marker = Null); - - // Proposed: lambda variant. - void info(function String () messageFn, - Exception? cause = Null, - Marker? marker = Null); - - // Same for trace/debug/warn/error and the polymorphic `log`. -} -``` - -Caller side: - -```ecstasy -logger.info(() -> $"expensive: {serializer.dump(thing)}"); -``` - -Implementation in `BasicLogger`: - -```ecstasy -void info(function String () messageFn, - Exception? cause = Null, - Marker? marker = Null) { - if (!sink.isEnabled(name, Info, marker)) { - return; - } - String msg = messageFn(); - sink.log(new LogEvent(name, Info, msg, ...)); -} -``` - -**Pros** - -- Zero language work — implementable today against the existing `lib_logging` POC. -- One-line change at every call site that wants laziness. -- Plays nicely with markers, MDC, and the fluent builder. -- Works the same way `slf4j-api`'s newer fluent `log(Supplier)` works, so the - mental model carries over. - -**Cons** - -- The closure allocates (one captured-frame object per call site, even when the level is - enabled). For hot paths in a long-running service this is non-trivial. Mitigation: - Ecstasy's compiler can in theory inline a closure that's used exactly once and - doesn't escape; whether it does today is a separable optimisation question. -- Slightly less ergonomic than the Kotlin lambda form, which uses `{ ... }` braces with - no arrow. We can revisit syntax if Ecstasy gains a Kotlin-style trailing-block sugar. - -### Option 2 — Build on top of issue #437 scope functions - -Issue [xtclang/xvm#437](https://github.com/xtclang/xvm/issues/437) proposes adding -Kotlin-style scope functions to Ecstasy. The proposed shape is a postfix `.{ ... }` -block where `this` is the receiver: - -```ecstasy -val adam = new Person("Adam").{ - age = 32; - city = "London"; -}; -``` - -This wouldn't directly give us the Kotlin `logger.info { msg }` form, but it would make -the level-guarded form *short enough to use everywhere*: - -```ecstasy -// Without #437: -if (logger.infoEnabled) { - logger.info($"expensive: {serializer.dump(thing)}"); -} - -// With #437 (logger.{ ... } opens a scope where `this` is `logger`): -logger.{ - if (infoEnabled) { - info($"expensive: {serializer.dump(thing)}"); - } -}; -``` - -That's still not great. Where #437 *would* shine is if it's combined with a small -extension method on `Logger`: - -```ecstasy -class Logger { - // pseudo-extension; the actual mechanism depends on Ecstasy's extension story - void infoIfEnabled(function String () messageFn) { - if (infoEnabled) { - info(messageFn()); - } - } -} - -// usage: -logger.infoIfEnabled(() -> $"expensive: {serializer.dump(thing)}"); -``` - -…which is just Option 1 with a different name. So #437 isn't a substitute for Option 1; -it's a useful complement when the caller wants to do _multiple_ things with the logger -in a level-guarded scope. - -### Option 3 — Compile-time elision via macros / annotation processor - -The Scala / Rust / C++ approach. The compiler sees `logger.debug("...", $arg)` and -expands it to a guarded form. In Ecstasy this would mean an XTC compiler enhancement -that recognises `Logger` calls and rewrites them. - -**Pros** - -- Zero overhead when disabled, even for the closure allocation. -- Caller code looks identical to the eager form. - -**Cons** - -- Compiler-side work; out of scope for the `lib_logging` v0. -- Couples the compiler to a specific library's identity, which is a smell. -- Mostly redundant with Option 1 once Ecstasy's optimizer handles single-use closures. - -We do not recommend this path. - -## Recommendation - -**Ship Option 1 in v0.1.** It's small, it's idiomatic, it lands today, and it makes the -library usable for hot-path code without ceremony. The signature lives in `Logger.x` -and the implementation in `BasicLogger.x`; both already have the surrounding shape. - -**Track #437 as it lands.** When scope functions arrive, document the `logger.{ ... }` -idiom for the cases where multiple log calls share a level guard. No code change needed -in `lib_logging`. - -**Keep Option 3 in mind only if profiling later shows closure allocation is a real -problem.** The right time to invest in compiler-level elision is after we have data -saying it matters. - -## Concrete next step - -Add the lambda overloads to `Logger.x` and `BasicLogger.x`. Update `../usage/ecstasy-vs-java-examples.md` -with a side-by-side showing: - -```kotlin -// Kotlin -logger.info { "size: ${expensiveSerialize(thing)}" } -``` - -```ecstasy -// Ecstasy -logger.info(() -> $"size: {expensiveSerialize(thing)}"); -``` - -That's the pull-quote that closes the gap for an SLF4J/Kotlin user evaluating the -library. - -## Wider survey takeaways - -A few patterns recur across the industry survey worth noting because they shape future -decisions: - -1. **The level check is *always* the first thing the implementation does.** Whether the - API is lambda-based, macro-based, or builder-based, every modern library short- - circuits at the level check before any other work. `lib_logging` already does this. -2. **The structured-data story (KV pairs) and the laziness story converge.** SLF4J 2.x's - `addArgument(Supplier)` and slog's `LogValuer` are both "evaluate the value lazily - per attribute". `lib_logging` could follow with `addArgument(function Object ())` on - the builder. Worth doing in the same v0.1 cut as the per-method lambda overloads. -3. **Macros are losing.** Of the languages that started with macro-based logging (C++, - Rust), the Rust crate has been creeping toward closure-based extensibility for years. - Macro-based laziness is a 1990s/early-2000s solution. Closure-based is what's left - standing in the languages that have first-class lambdas. - -This is consistent with picking Option 1 and only revisiting if data demands it. - - ---- - -_See also [../README.md](../README.md) for the full doc index and reading paths._ diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index ae3616e2e3..df172afe57 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -32,9 +32,9 @@ decide which shape survives. Direct links from each Ecstasy type to its SLF4J or Go `log/slog` counterpart are in [`api-cross-reference.md`](api-cross-reference.md). > Status note: both libraries are working comparison POCs. `lib_logging` is the -> recommended canonical facade and has the fuller SLF4J surface (66 focused XTC test +> recommended canonical facade and has the fuller SLF4J surface (70 focused XTC test > methods plus the injected manual demo), including async/composite/hierarchical/JSON -> backend building blocks. `lib_slogging` has the more compact slog surface (37 focused +> backend building blocks. `lib_slogging` has the more compact slog surface (41 focused > XTC test methods plus injected/manual coverage), with runtime injection, async > handler support, `lib_json` JSON rendering, redaction options, source metadata, > context binding, and handler derivation semantics implemented. @@ -868,14 +868,16 @@ audience while still leaving room for structured output and backend innovation. - JVM users recognize the call surface immediately: `Logger`, named loggers, `{}` message formatting, `Exception` cause handling, markers, MDC, and SLF4J 2.x fluent builders. +- Kotlin/SLF4J 2.x users get lazy message/value construction for hot paths: + suppliers are invoked only after the level/marker check accepts the event. - Backend authors get one narrow extension point, `LogSink`, with working examples for console, memory capture, JSON-Lines, async forwarding, multi-destination fanout, and hierarchical per-logger level routing. - Cloud and JSON users are not forced to parse message text. Structured key/value pairs travel on `LogEvent.keyValues`; `JsonLogSink` renders them directly and applies redaction policy before output. -- Runtime injection already works for the interpreter, and the native container now has - a default-name fallback for canonical `logging.Logger` injections. +- Runtime injection already works for the interpreter, including a default-name fallback + for canonical `logging.Logger` injections. `lib_slogging` remains valuable. It is smaller, cleaner, and closer to modern attribute-first logging. It also documents a real alternative for teams that prefer @@ -902,8 +904,8 @@ review material needed to choose between them. | Area | Contents | |---|---| -| `lib_logging/` | Recommended canonical SLF4J-shaped library with 66 focused XTC test methods, runtime injection, source metadata API, JSON/redaction sink, async wrapper, composite fanout, and hierarchical per-logger thresholds. | -| `lib_slogging/` | slog-shaped sibling library with 37 focused XTC test methods, runtime injection, `lib_json` JSON rendering, handler options/redaction, async wrapper, explicit source metadata, `LoggerContext`, handler derivation, and handler contract tests. | +| `lib_logging/` | Recommended canonical SLF4J-shaped library with 70 focused XTC test methods, runtime injection, lazy suppliers, source metadata API, JSON/redaction sink, async wrapper, composite fanout, and hierarchical per-logger thresholds. | +| `lib_slogging/` | slog-shaped sibling library with 41 focused XTC test methods, runtime injection, lazy message/attr suppliers, `lib_json` JSON rendering, handler options/redaction, async wrapper, explicit source metadata, `LoggerContext`, handler derivation, and handler contract tests. | | `doc/logging/` | Design comparison, API cross-reference, language/runtime questions, usage guides, and backend follow-up sketches. | The branch is now opinionated: Q-D6 is answered as `lib_logging`. Review can still diff --git a/doc/logging/usage/configuration.md b/doc/logging/usage/configuration.md index 363fefa692..d4c8ce6ece 100644 --- a/doc/logging/usage/configuration.md +++ b/doc/logging/usage/configuration.md @@ -299,7 +299,7 @@ make logging a production XDK subsystem. The boundary should be explicit: | Base backend primitives with no deployment dependencies | Implemented: `ConsoleLogSink`, `JsonLogSink`, `CompositeLogSink`, `HierarchicalLogSink`, `AsyncLogSink`, `MemoryLogSink`; slog equivalents include `TextHandler`, `JSONHandler`, `AsyncHandler`, `MemoryHandler`, `BoundHandler`. | Stays in core `lib_logging` if the dependency footprint remains small. | | File destinations, rolling files, cloud destinations, provider clients | Not implemented here. The examples use names such as `FileJsonLogSink`, `CloudLogSink`, and `CloudHandler` as sketches. | Optional first-class XDK backend libraries, for example `lib_logging_file` and `lib_logging_cloud`, or provider-specific modules such as `lib_logging_gcp`, `lib_logging_aws`, and `lib_logging_azure` if dependencies are heavy. | | JSON config schema, config parser, env/property/CLI override merge, file watching, hot reload services | Not implemented here. The examples use `LoggingConfig`, `SLoggingConfig`, `ConfiguredLogSink`, and `ConfiguredHandler` as sketches. | A first-class XDK config library, for example `lib_logging_config`, importing `logging`, `json`, and whichever backend modules it can instantiate. | -| Runtime defaults: automatic logger naming and automatic source-location capture | Partially sketched. The native injection POC exists; automatic naming/source capture remain compiler/runtime work. | Runtime/compiler, not the logging library itself. The library already has explicit fields/methods for the lowered values. | +| Runtime defaults: automatic logger naming and automatic source-location capture | Partially sketched. Runtime injection works for the manual interpreter demo; automatic naming/source capture remain compiler/runtime work. | Runtime/compiler, not the logging library itself. The library already has explicit fields/methods for the lowered values. | If the slog-shaped API wins, the module split should be the same after renaming the chosen facade to `lib_logging`: core logger/handler types in `lib_logging`, config diff --git a/doc/logging/usage/ecstasy-vs-java-examples.md b/doc/logging/usage/ecstasy-vs-java-examples.md index 18b5d7fe9f..f0fd45ce6e 100644 --- a/doc/logging/usage/ecstasy-vs-java-examples.md +++ b/doc/logging/usage/ecstasy-vs-java-examples.md @@ -60,6 +60,32 @@ if (logger.debugEnabled) { } ``` +For one-line expensive values, prefer lazy suppliers instead of a guard: + +**Kotlin (`kotlin-logging`):** + +```kotlin +logger.debug { "computed result: ${expensiveSerialize(thing)}" } +``` + +**Java (SLF4J 2.x fluent supplier):** + +```java +log.atDebug() + .addArgument(() -> expensiveSerialize(thing)) + .log("computed result: {}"); +``` + +**Ecstasy (`lib_logging`):** + +```ecstasy +logger.debug(() -> $"computed result: {expensiveSerialize(thing)}"); + +logger.atDebug() + .addLazyArgument(() -> expensiveSerialize(thing)) + .log("computed result: {}"); +``` + ## 3. Logging an exception **Java:** @@ -159,8 +185,10 @@ logger.atInfo() .log("payment {} failed for {}", paymentId, customer); ``` -When the level is disabled, both implementations short-circuit to a no-op builder so the -`addArgument` / `addKeyValue` calls are free. +When the level is disabled, the final `log(...)` call short-circuits before formatting, +MDC capture, `LogEvent` allocation, or lazy supplier evaluation. Eager `addArgument` and +`addKeyValue` expressions still run before the method call, as in Java; use +`addLazyArgument` / `addLazyKeyValue` for expensive values. ## 7. Acquiring a logger by name vs by class diff --git a/doc/logging/usage/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md index 8ac8a73ebd..ce20fc9ae1 100644 --- a/doc/logging/usage/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -7,10 +7,10 @@ look like in real code?" The accompanying executable sample lives at [`manualTests/src/main/x/TestLogger.x`](../../../manualTests/src/main/x/TestLogger.x). -> **Status (2026-05).** Runtime injection of `@Inject Logger logger;` is wired in -> the interpreter — `NativeContainer.ensureLogger` constructs a `BasicLogger` -> directly so per-fiber `MDC` survives injection. The JIT injector still needs the -> equivalent wiring; until it does, `--jit` runs won't resolve the injection. +> **Status (2026-05).** Runtime injection of `@Inject Logger logger;` is wired for +> the manual interpreter demo. The injected value is a `BasicLogger`, so per-fiber +> `MDC` survives injection. This POC keeps the public API independent of the temporary +> runtime wiring. ## API at a glance diff --git a/doc/logging/usage/lazy-logging.md b/doc/logging/usage/lazy-logging.md new file mode 100644 index 0000000000..a4e558d4bf --- /dev/null +++ b/doc/logging/usage/lazy-logging.md @@ -0,0 +1,173 @@ +# Lazy logging + +Lazy logging is a core requirement for the XDK logging API, not a future nice-to-have. +Disabled log calls must not build expensive messages, serialize payloads, or compute +structured values just to be dropped by the level check. + +## The requirement + +The accepted logging API **MUST** support one-line lazy logging: + +- message construction runs only after the backend accepts the level; +- structured value construction runs only after the backend accepts the level; +- callers can still use explicit `if (logger.debugEnabled)` guards for multi-statement work; +- the lazy call shape is familiar to Java, Kotlin, Go, and Scala users. + +## Why placeholder formatting is not enough + +SLF4J `{}` formatting defers string substitution, but it does not defer argument +evaluation: + +```java +log.debug("payload {}", expensiveSerialize(payload)); +``` + +`expensiveSerialize(payload)` runs before Java calls `debug(...)`, even when DEBUG is +off. The same is true in Ecstasy for eager arguments: + +```ecstasy +logger.debug("payload {}", [expensiveSerialize(payload)]); +``` + +The `MessageFormatter` work is skipped for disabled events, but the caller expression +already ran. Lazy logging closes that gap. + +## Industry shapes + +| Ecosystem | Lazy mechanism | Design lesson for Ecstasy | +|---|---|---| +| Kotlin logging | `logger.debug { "payload ${expensive()}" }`; inline blocks avoid allocation in Kotlin/JVM. | The call site should stay one line. | +| Java SLF4J 2.x | `log.atDebug().addArgument(() -> expensive()).log("payload {}")` and `log(() -> "...")` on the builder. | Suppliers belong behind the enabled check. | +| Java JUL | `logger.log(Level.FINE, () -> expensiveMessage())`. | Supplier messages are already a standard Java pattern. | +| Go `log/slog` | `Handler.Enabled` runs before record construction; expensive values can implement `slog.LogValuer`. | Attribute-first APIs need lazy values, not message formatting. | +| Scala / Rust / C++ | Macros expand logging calls into guarded code. | Compiler elision is powerful but should not be required for v0. | + +## `lib_logging`: SLF4J/Kotlin-style suppliers + +Use a lazy message supplier when the whole message is expensive: + +```kotlin +// Kotlin +logger.debug { "payload ${serializer.dump(payload)}" } +``` + +```java +// Java JUL / supplier-style APIs +logger.log(Level.FINE, () -> "payload " + serializer.dump(payload)); +``` + +```ecstasy +// Ecstasy lib_logging +logger.debug(() -> $"payload {serializer.dump(payload)}"); +``` + +Use the fluent builder when the message template is cheap but one argument or structured +field is expensive: + +```java +// Java SLF4J 2.x +log.atDebug() + .addArgument(() -> serializer.dump(payload)) + .addKeyValue("requestId", requestId) + .log("payload {}"); +``` + +```ecstasy +// Ecstasy lib_logging +logger.atDebug() + .addLazyArgument(() -> serializer.dump(payload)) + .addKeyValue("requestId", requestId) + .log("payload {}"); +``` + +For expensive structured fields: + +```ecstasy +logger.atDebug() + .addLazyKeyValue("payload", () -> serializer.dump(payload)) + .log("debug payload attached"); +``` + +### How it is implemented + +`BasicLogger` checks `LogSink.isEnabled(name, level, marker)` before invoking a +`MessageSupplier`. `BasicEventBuilder` freezes markers first so service sinks can see the +primary marker during enablement, then checks the sink. Only if enabled does it invoke +lazy message, argument, or key/value suppliers, run `MessageFormatter`, snapshot MDC, and +allocate `LogEvent`. + +Ecstasy cannot overload `addArgument(Object)` with `addArgument(function Object())` +because functions are also objects. The POC therefore uses explicit method names: +`addLazyArgument` and `addLazyKeyValue`. That is slightly less Java-like, but it is clear +at the call site and avoids ambiguous overload rules. + +## `lib_slogging`: slog-style lazy attrs + +Do not use `MessageFormatter` for slogging. The slog model has a complete message plus +structured attrs, so the lazy equivalent is a message supplier and lazy attribute values: + +```go +// Go slog-style idea +logger.Debug("payload", "json", expensiveValueThatMayResolveLazily) +``` + +```ecstasy +// Ecstasy lib_slogging +logger.debug("payload", [ + Attr.lazy("json", () -> serializer.dump(payload)), +]); +``` + +Lazy message construction is also available: + +```ecstasy +logger.debug(() -> $"payload {serializer.dump(payload)}", [ + Attr.of("requestId", requestId), +]); +``` + +`Attr.lazy` is the POC's compact equivalent of Go slog's `LogValuer`: a value wrapper +that resolves only for enabled records. `Logger.emit` performs `Handler.enabled(level)` +first, then resolves lazy attrs, constructs the `Record`, and calls `Handler.handle`. +`BoundHandler` also resolves lazy attrs attached by `logger.with(...)` when an enabled +record is handled. + +One Ecstasy-specific rule matters: `Attr` is a `const`, so the lazy supplier must capture +only passable state. Capturing a service reference or immutable value is fine. Capturing +and mutating an ordinary local variable is not, because the supplier carrier is frozen. +For mutable request state, resolve to a stable value before `Attr.lazy`, capture a service, +or use the direct lazy message overload when the deferred computation does not need to +be stored inside an `Attr`. + +## When to still use explicit guards + +Lazy suppliers are for one-line expensive message/value construction. Use explicit level +guards when the logging side work is multi-step or has side effects: + +```ecstasy +if (logger.debugEnabled) { + DebugSnapshot snapshot = diagnostics.snapshot(order); + logger.debug("order diagnostics {}", [snapshot.summary]); + metrics.debugSnapshotCount++; +} +``` + +The same rule applies to slogging: + +```ecstasy +if (logger.debugEnabled) { + PayloadDump dump = diagnostics.dump(payload); + logger.debug("payload dump", [Attr.of("dump", dump.text)]); +} +``` + +## Compiler optimization path + +The current POC uses runtime function values. That is enough for API semantics and is +the same conceptual shape as Java suppliers. Later compiler work can optimize single-use +non-escaping closures, or add Kotlin-style trailing-block syntax if Ecstasy wants that +surface. The API does not depend on that work. + +--- + +_See also [../README.md](../README.md) for the requirements list and reading paths._ diff --git a/doc/logging/usage/slog-parity.md b/doc/logging/usage/slog-parity.md index f1c7ab7e28..8fe537c3cc 100644 --- a/doc/logging/usage/slog-parity.md +++ b/doc/logging/usage/slog-parity.md @@ -39,6 +39,7 @@ as `com.acme.payments.stripe` with a Logback longest-prefix level rule. | `logger.LogAttrs(ctx, level, msg, attrs...)` | Same `logger.log(level, message, attrs)` path; attrs are already typed. | | `logger.Enabled(ctx, level)` | `logger.enabled(level)` | | `AddSource` output | `logger.logAt(level, message, sourceFile, sourceLine, attrs)` | +| lazy message helpers used by wrappers | `logger.debug(() -> expensiveMessage(), attrs)` and the same shape for `info` / `warn` / `error` / `log` / `logAt` | The intentional call-shape differences are small: @@ -57,11 +58,17 @@ The intentional call-shape differences are small: | `slog.Bool("audit", true)` | `Attr.of("audit", True)` | | `slog.Any("err", err)` | `Attr.of("err", e)` or `cause=e` | | `slog.Group("user", ...)` | `Attr.group("user", [...])` | +| values implementing `slog.LogValuer` | `Attr.lazy("key", () -> expensiveValue)` | Go has many typed helper functions because it has no common `Object` parent. Ecstasy does, so a single `Attr.of` is enough for the POC. A production handler can still render strings, numbers, booleans, times, durations, exceptions, and groups differently. +`Attr.lazy` is the POC's `LogValuer` equivalent. `Logger` calls `Handler.enabled(level)` +first, then resolves lazy attrs before constructing the `Record`. Because `Attr` is a +`const`, the supplier must capture passable state: immutable values and service +references are fine; mutating an ordinary local variable from inside the supplier is not. + ## `Handler` | Go `log/slog.Handler` | `lib_slogging.Handler` | diff --git a/lib_logging/README.md b/lib_logging/README.md index 3f3e025e44..a6779f90c3 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -3,10 +3,11 @@ Experimental SLF4J-shaped logging library for Ecstasy. > **Status:** Working POC. The core API, default sinks, MDC, markers, structured -> key/value events, logger interning, runtime `@Inject Logger logger;` wiring, and -> unit tests are in this branch. The base backend layer now includes async, -> composite, hierarchical-level, and JSON/redaction sinks. JIT-side injection and -> full configuration-file loading remain future work; see [`doc/logging/`](../doc/logging). +> key/value events, lazy message/value suppliers, logger interning, runtime +> `@Inject Logger logger;` wiring, and unit tests are in this branch. The base backend +> layer now includes async, +> composite, hierarchical-level, and JSON/redaction sinks. Full configuration-file +> loading remains future work; see [`doc/logging/`](../doc/logging). > **POC naming note:** `lib_slogging` exists beside this module only for comparison. > The XDK should settle on one injectable logging design and ship it as `lib_logging`. @@ -38,5 +39,6 @@ All design docs live at the repo root under [`doc/logging/`](../doc/logging): | [`design.md`](../doc/logging/design/design.md) | Architecture, module layout, and API/implementation boundary. | | [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) | SLF4J 2.x type and method mapping. | | [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) | Java SLF4J examples next to equivalent Ecstasy code. | +| [`lazy-logging.md`](../doc/logging/usage/lazy-logging.md) | Kotlin/Java supplier-style lazy logging and the Ecstasy call shape. | | [`custom-sinks.md`](../doc/logging/usage/custom-sinks.md) | Guide to writing a custom sink. | | [`open-questions.md`](../doc/logging/open-questions.md) | Decision tracker and remaining runtime/compiler/backend follow-up. | diff --git a/lib_logging/src/main/x/logging.x b/lib_logging/src/main/x/logging.x index 935b4a543d..f2923ed41f 100644 --- a/lib_logging/src/main/x/logging.x +++ b/lib_logging/src/main/x/logging.x @@ -55,4 +55,24 @@ */ module logging.xtclang.org { package json import json.xtclang.org; + + /** + * Lazy message supplier used by [Logger] and [LoggingEventBuilder]. + * + * The logging implementation invokes this function only after the active [LogSink] + * accepts the event's level (and primary marker). This mirrors Java `Supplier` + * logging APIs and Kotlin logging blocks, where expensive message construction stays + * behind the level check. + */ + typedef function String() as MessageSupplier; + + /** + * Lazy structured value supplier used by the fluent builder for positional `{}` arguments + * and structured key/value pairs. + * + * The supplier is resolved immediately before [LogEvent] construction, never for a disabled + * event. The returned value must obey the same service-boundary rules as any other log + * argument or structured value. + */ + typedef function Object() as ObjectSupplier; } diff --git a/lib_logging/src/main/x/logging/BasicEventBuilder.x b/lib_logging/src/main/x/logging/BasicEventBuilder.x index d24fc655fa..e0dbef2ab6 100644 --- a/lib_logging/src/main/x/logging/BasicEventBuilder.x +++ b/lib_logging/src/main/x/logging/BasicEventBuilder.x @@ -9,11 +9,14 @@ class BasicEventBuilder(BasicLogger logger, Level level) implements LoggingEventBuilder { - private String? message = Null; - private Object[] args = new Object[]; - private Marker[] markers = new Marker[]; - private Exception? cause = Null; - private Map keyValues = new ListMap(); + private String? message = Null; + private MessageSupplier? lazyMessage = Null; + private Object[] args = new Object[]; + private Boolean[] lazyArgs = new Boolean[]; + private Marker[] markers = new Marker[]; + private Exception? cause = Null; + private Map keyValues = new ListMap(); + private Map lazyKeyValues = new ListMap(); /** * Replace the pending message pattern. Mirrors SLF4J @@ -22,6 +25,17 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override LoggingEventBuilder setMessage(String message) { this.message = message; + this.lazyMessage = Null; + return this; + } + + /** + * Replace the pending message with a lazily computed message. + */ + @Override + LoggingEventBuilder setMessage(MessageSupplier message) { + this.message = Null; + this.lazyMessage = message; return this; } @@ -31,6 +45,17 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override LoggingEventBuilder addArgument(Object value) { args.add(value); + lazyArgs.add(False); + return this; + } + + /** + * Append one lazily computed positional `{}` argument. + */ + @Override + LoggingEventBuilder addLazyArgument(ObjectSupplier value) { + args.add(value); + lazyArgs.add(True); return this; } @@ -60,6 +85,17 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override LoggingEventBuilder addKeyValue(String key, Object value) { keyValues.put(key, value); + lazyKeyValues.put(key, False); + return this; + } + + /** + * Attach one lazily computed structured key/value pair. + */ + @Override + LoggingEventBuilder addLazyKeyValue(String key, ObjectSupplier value) { + keyValues.put(key, value); + lazyKeyValues.put(key, True); return this; } @@ -69,7 +105,9 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override void log() { if (String m ?= message) { - logger.emitWith(level, m, frozen(args), cause, frozenMarkers(), frozenKVs()); + emit(m); + } else if (MessageSupplier supplier ?= lazyMessage) { + emit(supplier); } } @@ -78,7 +116,15 @@ class BasicEventBuilder(BasicLogger logger, Level level) */ @Override void log(String message) { - logger.emitWith(level, message, frozen(args), cause, frozenMarkers(), frozenKVs()); + emit(message); + } + + /** + * Emit using a lazily computed final message pattern. + */ + @Override + void log(MessageSupplier message) { + emit(message); } /** @@ -86,8 +132,8 @@ class BasicEventBuilder(BasicLogger logger, Level level) */ @Override void log(String format, Object arg) { - args.add(arg); - logger.emitWith(level, format, frozen(args), cause, frozenMarkers(), frozenKVs()); + addArgument(arg); + emit(format); } /** @@ -95,9 +141,9 @@ class BasicEventBuilder(BasicLogger logger, Level level) */ @Override void log(String format, Object arg1, Object arg2) { - args.add(arg1); - args.add(arg2); - logger.emitWith(level, format, frozen(args), cause, frozenMarkers(), frozenKVs()); + addArgument(arg1); + addArgument(arg2); + emit(format); } /** @@ -106,9 +152,39 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override void log(String format, Object[] args) { for (Object arg : args) { - this.args.add(arg); + addArgument(arg); } - logger.emitWith(level, format, frozen(this.args), cause, frozenMarkers(), frozenKVs()); + emit(format); + } + + /** + * Emit an eager message after the level/marker check. Lazy args and key/value suppliers are + * intentionally resolved only after this check succeeds. + */ + private void emit(String message) { + Marker[] frozenMarkers = frozenMarkers(); + if (enabled(frozenMarkers)) { + logger.emitWith(level, message, frozenArgs(), cause, frozenMarkers, frozenKVs()); + } + } + + /** + * Emit a lazy message after the level/marker check. This is the builder equivalent of + * SLF4J 2.x `log(Supplier)` and Kotlin logging's lambda block. + */ + private void emit(MessageSupplier message) { + Marker[] frozenMarkers = frozenMarkers(); + if (enabled(frozenMarkers)) { + logger.emitWith(level, message(), frozenArgs(), cause, frozenMarkers, frozenKVs()); + } + } + + /** + * Check the sink using the first marker as the primary marker, matching [BasicLogger.emitWith]. + */ + private Boolean enabled(Marker[] markers) { + Marker? primary = markers.empty ? Null : markers[0]; + return logger.isEnabled(level, primary); } /** @@ -138,7 +214,9 @@ class BasicEventBuilder(BasicLogger logger, Level level) } ListMap snapshot = new ListMap(); for ((String k, Object v) : keyValues) { - snapshot.put(k, v); + snapshot.put(k, lazyKeyValues.getOrDefault(k, False) + ? v.as(ObjectSupplier)() + : v); } return snapshot.makeImmutable(); } @@ -148,7 +226,18 @@ class BasicEventBuilder(BasicLogger logger, Level level) * it can cross service boundaries on its way to the sink. Equivalent to SLF4J's * `EventArgArray` materialisation step. */ - private static Object[] frozen(Object[] mutable) { - return mutable.toArray(Constant); + private Object[] frozenArgs() { + if (args.empty) { + return []; + } + + Object[] snapshot = new Array(args.size); + for (Int i : 0 ..< args.size) { + Object value = args[i]; + snapshot.add(lazyArgs[i] + ? value.as(ObjectSupplier)() + : value); + } + return snapshot.toArray(Constant); } } diff --git a/lib_logging/src/main/x/logging/BasicLogger.x b/lib_logging/src/main/x/logging/BasicLogger.x index 47af083d54..a8cf1de74e 100644 --- a/lib_logging/src/main/x/logging/BasicLogger.x +++ b/lib_logging/src/main/x/logging/BasicLogger.x @@ -80,42 +80,75 @@ const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) emit(Trace, message, arguments, cause, marker); } + @Override + void trace(MessageSupplier message, Exception? cause = Null, Marker? marker = Null) { + emitLazy(Trace, message, cause, marker); + } + @Override void debug(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { emit(Debug, message, arguments, cause, marker); } + @Override + void debug(MessageSupplier message, Exception? cause = Null, Marker? marker = Null) { + emitLazy(Debug, message, cause, marker); + } + @Override void info(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { emit(Info, message, arguments, cause, marker); } + @Override + void info(MessageSupplier message, Exception? cause = Null, Marker? marker = Null) { + emitLazy(Info, message, cause, marker); + } + @Override void warn(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { emit(Warn, message, arguments, cause, marker); } + @Override + void warn(MessageSupplier message, Exception? cause = Null, Marker? marker = Null) { + emitLazy(Warn, message, cause, marker); + } + @Override void error(String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { emit(Error, message, arguments, cause, marker); } + @Override + void error(MessageSupplier message, Exception? cause = Null, Marker? marker = Null) { + emitLazy(Error, message, cause, marker); + } + @Override void log(Level level, String message, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { emit(level, message, arguments, cause, marker); } + @Override + void log(Level level, MessageSupplier message, Exception? cause = Null, Marker? marker = Null) { + emitLazy(level, message, cause, marker); + } + @Override void logAt(Level level, String message, String sourceFile, Int sourceLine, Object[] arguments = [], Exception? cause = Null, Marker? marker = Null) { - Marker[] markers = []; - if (Marker m ?= marker) { - markers = [m.freeze()]; - } + Marker[] markers = frozen(marker); emitWith(level, message, arguments, cause, markers, [], sourceFile=sourceFile, sourceLine=sourceLine); } + @Override + void logAt(Level level, MessageSupplier message, String sourceFile, Int sourceLine, + Exception? cause = Null, Marker? marker = Null) { + emitLazy(level, message, cause, marker, sourceFile=sourceFile, sourceLine=sourceLine); + } + @Override LoggingEventBuilder atTrace() = builderFor(Trace); @Override @@ -139,12 +172,35 @@ const BasicLogger(String name, LogSink sink, LoggerRegistry? registry) * directly with an already-frozen `Marker[]`. */ private void emit(Level level, String message, Object[] arguments, Exception? cause, Marker? marker) { - Marker[] markers = []; + Marker[] markers = frozen(marker); + emitWith(level, message, arguments, cause, markers, []); + } + + /** + * Lazy-message equivalent of [emit]. The supplier runs only after the sink accepts the + * level/primary-marker check; when disabled, the caller's expensive message construction + * never executes. This is the Ecstasy analogue of Java/Kotlin supplier/block logging. + */ + private void emitLazy(Level level, MessageSupplier message, Exception? cause, Marker? marker, + String? sourceFile = Null, Int sourceLine = -1) { + Marker[] markers = frozen(marker); + Marker? primary = markers.empty ? Null : markers[0]; + if (!sink.isEnabled(name, level, primary)) { + return; + } + + emitWith(level, message(), [], cause, markers, [], + sourceFile=sourceFile, sourceLine=sourceLine); + } + + /** + * Freeze an optional single marker so it can safely cross a service sink boundary. + */ + private static Marker[] frozen(Marker? marker) { if (Marker m ?= marker) { - // See the freeze rationale on [emitWith]. - markers = [m.freeze()]; + return [m.freeze()]; } - emitWith(level, message, arguments, cause, markers, []); + return []; } /** diff --git a/lib_logging/src/main/x/logging/Logger.x b/lib_logging/src/main/x/logging/Logger.x index dd30181754..689e0f38e2 100644 --- a/lib_logging/src/main/x/logging/Logger.x +++ b/lib_logging/src/main/x/logging/Logger.x @@ -45,9 +45,12 @@ interface Logger { */ @RO Boolean traceEnabled; /** - * Cheap `Debug` level check. Use this before constructing expensive debug - * arguments; `{}` formatting is lazy, but argument expressions are still evaluated - * by the caller before the method call. + * Cheap `Debug` level check. + * + * Use this for multi-statement guarded work. For single expensive messages or values, + * prefer the lazy supplier overloads (`logger.debug(() -> "...")` or + * `logger.atDebug().addArgument(() -> value).log("...")`), which perform this same + * enabled check before invoking the supplied function. */ @RO Boolean debugEnabled; /** @@ -79,6 +82,16 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit a `Trace` event whose complete message is built lazily after the level check. + * + * This is the Ecstasy equivalent of Java `Logger.log(Level, Supplier)` and + * Kotlin logging blocks such as `logger.trace { "..." }`. + */ + void trace(MessageSupplier message, + Exception? cause = Null, + Marker? marker = Null); + /** * Emit a `Debug` event using SLF4J `{}` placeholder formatting. */ @@ -87,6 +100,13 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit a `Debug` event whose complete message is built lazily after the level check. + */ + void debug(MessageSupplier message, + Exception? cause = Null, + Marker? marker = Null); + /** * Emit an `Info` event using SLF4J `{}` placeholder formatting. */ @@ -95,6 +115,13 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit an `Info` event whose complete message is built lazily after the level check. + */ + void info (MessageSupplier message, + Exception? cause = Null, + Marker? marker = Null); + /** * Emit a `Warn` event using SLF4J `{}` placeholder formatting. */ @@ -103,6 +130,13 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit a `Warn` event whose complete message is built lazily after the level check. + */ + void warn (MessageSupplier message, + Exception? cause = Null, + Marker? marker = Null); + /** * Emit an `Error` event using SLF4J `{}` placeholder formatting. */ @@ -111,6 +145,13 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit an `Error` event whose complete message is built lazily after the level check. + */ + void error(MessageSupplier message, + Exception? cause = Null, + Marker? marker = Null); + /** * Emit at a runtime-chosen level. Equivalent to `if (level == Info) info(...) else if ...`. */ @@ -120,6 +161,14 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit at a runtime-chosen level with lazy message construction. + */ + void log(Level level, + MessageSupplier message, + Exception? cause = Null, + Marker? marker = Null); + /** * Emit at a runtime-chosen level with explicit source metadata. * @@ -135,6 +184,19 @@ interface Logger { Exception? cause = Null, Marker? marker = Null); + /** + * Emit at a runtime-chosen level with explicit source metadata and lazy message construction. + * + * This is the lowering target for future compiler-provided source capture when the caller + * also wants Kotlin-style lazy message construction. + */ + void logAt(Level level, + MessageSupplier message, + String sourceFile, + Int sourceLine, + Exception? cause = Null, + Marker? marker = Null); + // ---- Fluent (SLF4J 2.x style) builder ------------------------------------------------------ /** diff --git a/lib_logging/src/main/x/logging/LoggingEventBuilder.x b/lib_logging/src/main/x/logging/LoggingEventBuilder.x index 666af3d4dc..c9b25460c2 100644 --- a/lib_logging/src/main/x/logging/LoggingEventBuilder.x +++ b/lib_logging/src/main/x/logging/LoggingEventBuilder.x @@ -9,10 +9,11 @@ * sinks still get a chance to enable or suppress the event. If the sink rejects the event, * no formatting, MDC snapshot, or `LogEvent` allocation happens. * - * This is deliberately a v0 trade-off: the builder stores values passed to - * `addArgument` / `addKeyValue`, so caller expressions have already been evaluated by - * the time those methods run. Future lazy overloads can add supplier-valued arguments; - * see `doc/logging/future/lazy-logging.md`. + * The builder supports eager values and supplier-valued lazy values. Lazy suppliers are + * resolved only after the sink accepts the level/primary-marker check. Java SLF4J can model + * this as `addArgument(Supplier)`; Ecstasy's `function Object()` is itself an `Object`, so + * the POC uses explicit `addLazyArgument` / `addLazyKeyValue` names to keep overload resolution + * unambiguous. See `doc/logging/usage/lazy-logging.md`. * * Example: * @@ -29,11 +30,21 @@ interface LoggingEventBuilder { */ LoggingEventBuilder setMessage(String message); + /** + * Set (or replace) a lazily computed message. The supplier runs only if the event is enabled. + */ + LoggingEventBuilder setMessage(MessageSupplier message); + /** * Append an argument used to substitute the next `{}` placeholder in the message. */ LoggingEventBuilder addArgument(Object value); + /** + * Append a lazily computed `{}` argument. The supplier runs only if the event is enabled. + */ + LoggingEventBuilder addLazyArgument(ObjectSupplier value); + /** * Attach a marker. Repeated calls add multiple markers. */ @@ -51,6 +62,11 @@ interface LoggingEventBuilder { */ LoggingEventBuilder addKeyValue(String key, Object value); + /** + * Attach a lazily computed key/value pair. The supplier runs only if the event is enabled. + */ + LoggingEventBuilder addLazyKeyValue(String key, ObjectSupplier value); + /** * Materialize and emit. After `log` is called the builder must not be reused. */ @@ -61,6 +77,11 @@ interface LoggingEventBuilder { */ void log(String message); + /** + * Convenience: same as `setMessage(message).log()`, with lazy message construction. + */ + void log(MessageSupplier message); + /** * Convenience: same as `setMessage(format).addArgument(arg).log()`. */ diff --git a/lib_logging/src/test/x/LoggingTest/LazyLoggingTest.x b/lib_logging/src/test/x/LoggingTest/LazyLoggingTest.x new file mode 100644 index 0000000000..28165281af --- /dev/null +++ b/lib_logging/src/test/x/LoggingTest/LazyLoggingTest.x @@ -0,0 +1,92 @@ +import logging.BasicLogger; +import logging.Logger; + +/** + * Verifies lazy logging semantics: supplier bodies must run only after the sink accepts the + * level/primary-marker check. This covers the Kotlin-style direct-message API and the + * SLF4J 2.x-style fluent builder. + */ +class LazyLoggingTest { + + @Test + void shouldNotEvaluateLazyMessageWhenLevelDisabled() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("lazy.message.disabled", sink); + @Volatile Int calls = 0; + + sink.setLevel(logging.Level.Info); + logger.debug(() -> { + ++calls; + return "expensive debug message"; + }); + + assert calls == 0; + assert sink.events.empty; + } + + @Test + void shouldEvaluateLazyMessageWhenEnabled() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("lazy.message.enabled", sink); + @Volatile Int calls = 0; + + logger.info(() -> { + ++calls; + return "expensive info message"; + }); + + assert calls == 1; + assert sink.events.size == 1; + assert sink.events[0].message == "expensive info message"; + } + + @Test + void shouldNotEvaluateLazyBuilderValuesWhenLevelDisabled() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("lazy.builder.disabled", sink); + @Volatile Int argCalls = 0; + @Volatile Int kvCalls = 0; + + sink.setLevel(logging.Level.Info); + logger.atDebug() + .addLazyArgument(() -> { + ++argCalls; + return "argument"; + }) + .addLazyKeyValue("requestId", () -> { + ++kvCalls; + return "r_42"; + }) + .log("value {}"); + + assert argCalls == 0; + assert kvCalls == 0; + assert sink.events.empty; + } + + @Test + void shouldEvaluateLazyBuilderValuesWhenEnabled() { + ListLogSink sink = new ListLogSink(); + Logger logger = new BasicLogger("lazy.builder.enabled", sink); + @Volatile Int argCalls = 0; + @Volatile Int kvCalls = 0; + + logger.atInfo() + .addLazyArgument(() -> { + ++argCalls; + return "argument"; + }) + .addLazyKeyValue("requestId", () -> { + ++kvCalls; + return "r_42"; + }) + .log("value {}"); + + assert argCalls == 1; + assert kvCalls == 1; + assert sink.events.size == 1; + assert sink.events[0].message == "value argument"; + assert sink.events[0].arguments[0].as(String) == "argument"; + assert sink.events[0].keyValues.getOrNull("requestId").as(String) == "r_42"; + } +} diff --git a/lib_slogging/README.md b/lib_slogging/README.md index e49caa3f54..7ea8d9bbd5 100644 --- a/lib_slogging/README.md +++ b/lib_slogging/README.md @@ -4,8 +4,9 @@ Experimental Go `log/slog`-shaped structured logging library for Ecstasy. > **Status:** Working comparison POC. The core `Logger` / `Handler` / `Record` / > `Attr` / `Level` shape, text/JSON/no-op/memory handlers, derived loggers, groups, -> custom levels, source metadata, `LoggerContext`, runtime injection, async handler -> wrapping, handler options/redaction, and unit tests are in this branch. +> custom levels, lazy message/attr suppliers, source metadata, `LoggerContext`, +> runtime injection, async handler wrapping, handler options/redaction, and unit tests +> are in this branch. > `JSONHandler` renders through `lib_json`. > **POC naming note:** this module exists beside `lib_logging` only so the branch can @@ -30,6 +31,10 @@ requestLog.info("payment processed", [ Attr.of("amount", amount), Attr.of("currency", currency), ]); + +requestLog.debug("payload", [ + Attr.lazy("json", () -> serializer.dump(payload)), +]); ``` There are no markers and no MDC in the base shape. Categorisation, errors, and @@ -47,6 +52,7 @@ Start with the repo-level docs: | [`doc/logging/lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) | Side-by-side design comparison and reviewer questions. | | [`doc/logging/api-cross-reference.md`](../doc/logging/api-cross-reference.md) | Official Go `log/slog` links mapped to each Ecstasy type and the local differences. | | [`doc/logging/usage/slog-parity.md`](../doc/logging/usage/slog-parity.md) | Go `log/slog` method/type mapping and intentional Ecstasy differences. | +| [`doc/logging/usage/lazy-logging.md`](../doc/logging/usage/lazy-logging.md) | Lazy message and attribute construction in both POC APIs. | | [`doc/logging/usage/custom-handlers.md`](../doc/logging/usage/custom-handlers.md) | Guide to writing custom handlers. | | [`doc/logging/cloud-integration.md`](../doc/logging/cloud-integration.md) | How the API shape maps to cloud logging systems. | diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index 71afcaa6c1..59a06a515a 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -29,8 +29,9 @@ * # Status * * **This module is a working comparison POC.** The core API, level checks, derived - * loggers, groups, custom levels, runtime injection, source metadata, context binding, - * and memory/text/JSON/no-op handlers are implemented and covered by unit tests. + * loggers, groups, custom levels, lazy message/attr suppliers, runtime injection, source + * metadata, context binding, and memory/text/JSON/no-op handlers are implemented and + * covered by unit tests. * * # API / Implementation boundary * @@ -60,4 +61,21 @@ */ module slogging.xtclang.org { package json import json.xtclang.org; + + /** + * Lazy message supplier used by [Logger]. + * + * The logger invokes this function only after [Handler.enabled] accepts the record's + * level. This is the slog-shaped equivalent of Kotlin logging blocks and Java + * supplier-based logging APIs. + */ + typedef function String() as MessageSupplier; + + /** + * Lazy attribute value supplier used by [Attr.lazy]. + * + * This mirrors Go slog's `LogValuer` idea: expensive structured values can be + * represented at the call site without constructing them for disabled records. + */ + typedef function Object() as ObjectSupplier; } diff --git a/lib_slogging/src/main/x/slogging/Attr.x b/lib_slogging/src/main/x/slogging/Attr.x index 6eed99e3d9..aa232419b8 100644 --- a/lib_slogging/src/main/x/slogging/Attr.x +++ b/lib_slogging/src/main/x/slogging/Attr.x @@ -25,6 +25,15 @@ * its fields are auto-frozen on construction. Trying to put a mutable class instance in * here will fail with the standard "not freezable" diagnostic. * + * # Lazy values + * + * Use [lazy] for expensive per-record values: + * + * logger.debug("payload", [Attr.lazy("json", () -> serialize(payload))]); + * + * The logger resolves lazy values only after [Handler.enabled] accepts the record. This + * is the Ecstasy POC's equivalent of Go slog values that implement `LogValuer`. + * * # Groups * * Setting `value` to an `Attr[]` represents a nested group, equivalent to slog's @@ -45,4 +54,43 @@ const Attr(String key, Object value) { * `slog.Group(name, attrs...)`. */ static Attr group(String name, Attr[] attrs) = new Attr(name, attrs); + + /** + * Construct an attribute whose value is computed lazily after the level check. + * + * Prefer this for expensive per-call data. Context attrs passed to `Logger.with(...)` + * should normally be stable eager values; if they are lazy, [BoundHandler] resolves + * them when an enabled record is handled. + */ + static Attr lazy(String key, ObjectSupplier value) = new Attr(key, new LazyValue(value)); + + /** + * Return this attr with any lazy value resolved. Nested groups are resolved + * recursively so handlers see ordinary values. + */ + Attr resolved() { + if (LazyValue lazy := value.is(LazyValue)) { + return new Attr(key, lazy.resolve()); + } + if (Attr[] attrs := value.is(Attr[])) { + return Attr.group(key, resolveAll(attrs)); + } + return this; + } + + /** + * Resolve lazy values in an attr array, returning an immutable array suitable for + * a [Record] or handler boundary. + */ + static Attr[] resolveAll(Attr[] attrs) { + if (attrs.empty) { + return []; + } + + Attr[] resolved = new Array(attrs.size); + for (Attr attr : attrs) { + resolved.add(attr.resolved()); + } + return resolved.toArray(Constant); + } } diff --git a/lib_slogging/src/main/x/slogging/BoundHandler.x b/lib_slogging/src/main/x/slogging/BoundHandler.x index 4c8fd5590a..1a37c4dccc 100644 --- a/lib_slogging/src/main/x/slogging/BoundHandler.x +++ b/lib_slogging/src/main/x/slogging/BoundHandler.x @@ -46,11 +46,12 @@ const BoundHandler(Handler delegate, Attr[] attrs, String? groupName) Boolean enabled(Level level) = delegate.enabled(level); /** - * Apply the bound attrs/group to the record, then forward to the delegate. + * Apply the bound attrs/group to the record, resolve any [Attr.lazy] values now + * that the logger has already passed the level check, then forward to the delegate. */ @Override void handle(Record record) { - Attr[] merged = merge(attrs, record.attrs); + Attr[] merged = merge(Attr.resolveAll(attrs), Attr.resolveAll(record.attrs)); if (String group ?= groupName) { merged = merged.empty ? [] diff --git a/lib_slogging/src/main/x/slogging/LazyValue.x b/lib_slogging/src/main/x/slogging/LazyValue.x new file mode 100644 index 0000000000..f2bd478819 --- /dev/null +++ b/lib_slogging/src/main/x/slogging/LazyValue.x @@ -0,0 +1,14 @@ +/** + * Internal carrier for [Attr.lazy] values. + * + * Go slog exposes this idea as the `LogValuer` interface on values. The POC keeps the + * public call shape smaller: callers create `Attr.lazy("key", () -> value)`, and the + * logger/handler boundary resolves this wrapper after the level check accepts the record. + */ +const LazyValue(ObjectSupplier supplier) { + + /** + * Evaluate the deferred value. + */ + Object resolve() = supplier(); +} diff --git a/lib_slogging/src/main/x/slogging/Logger.x b/lib_slogging/src/main/x/slogging/Logger.x index 4e6860902e..83e366a747 100644 --- a/lib_slogging/src/main/x/slogging/Logger.x +++ b/lib_slogging/src/main/x/slogging/Logger.x @@ -16,6 +16,7 @@ * # The user-facing API * * logger.debug("computed", [Attr.of("ms", 12)]); + * logger.debug(() -> expensiveMessage(), [Attr.lazy("payload", () -> payloadJson)]); * logger.info("processing", [Attr.of("path", req.path)]); * logger.warn("retrying", [Attr.of("attempt", 3)]); * logger.error("failed", [Attr.of("err", e)]); @@ -33,11 +34,12 @@ * * # POC status * - * The `log` method is implemented (level-check fast path, record construction, forward - * to handler), and [logAt] provides explicit source metadata for callers or future - * compiler/runtime sugar. Runtime injection for `@Inject slogging.Logger logger;` is - * registered by `NativeContainer`. There is no `LogAttrs(ctx, level, msg, attrs...)` - * form; [LoggerContext] is the Ecstasy-shaped optional context helper. + * The `log` method is implemented (level-check fast path, lazy supplier resolution, + * record construction, forward to handler), and [logAt] provides explicit source metadata + * for callers or future compiler/runtime sugar. Runtime injection for + * `@Inject slogging.Logger logger;` is registered by `NativeContainer`. There is no + * `LogAttrs(ctx, level, msg, attrs...)` form; [LoggerContext] is the Ecstasy-shaped + * optional context helper. */ const Logger(Handler handler) implements Orderable { @@ -75,7 +77,8 @@ const Logger(Handler handler) @RO Boolean debugEnabled.get() = handler.enabled(Level.Debug); /** - * Cheap `Info` enabled check. Use before constructing expensive attributes. + * Cheap `Info` enabled check. Use for multi-statement guarded work; for one-line + * expensive values, prefer `logger.info(() -> "...", [Attr.lazy("k", () -> v)])`. */ @RO Boolean infoEnabled.get() = handler.enabled(Level.Info); @@ -103,18 +106,36 @@ const Logger(Handler handler) void debug(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Debug, message, extra, cause); + /** + * Emit a `Debug` record whose message is computed after the handler's enabled check. + */ + void debug(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Debug, message, extra, cause); + /** * Emit an `Info` record. */ void info(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Info, message, extra, cause); + /** + * Emit an `Info` record whose message is computed after the handler's enabled check. + */ + void info(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Info, message, extra, cause); + /** * Emit a `Warn` record. */ void warn(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Warn, message, extra, cause); + /** + * Emit a `Warn` record whose message is computed after the handler's enabled check. + */ + void warn(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Warn, message, extra, cause); + /** * Emit an `Error` record. `cause` is an Ecstasy convenience; the Go slog idiom is * usually `slog.Any("err", err)`. Either form can be rendered by a handler. @@ -122,6 +143,12 @@ const Logger(Handler handler) void error(String message, Attr[] extra = [], Exception? cause = Null) = log(Level.Error, message, extra, cause); + /** + * Emit an `Error` record whose message is computed after the handler's enabled check. + */ + void error(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + log(Level.Error, message, extra, cause); + /** * Open-level emission. Use for custom levels. * @@ -133,6 +160,13 @@ const Logger(Handler handler) emit(level, message, extra, cause, Null, -1); } + /** + * Open-level emission with lazy message construction. + */ + void log(Level level, MessageSupplier message, Attr[] extra = [], Exception? cause = Null) { + emit(level, message, extra, cause, Null, -1); + } + /** * Open-level emission with explicit source metadata. * @@ -146,6 +180,14 @@ const Logger(Handler handler) emit(level, message, extra, cause, sourceFile, sourceLine); } + /** + * Open-level emission with explicit source metadata and lazy message construction. + */ + void logAt(Level level, MessageSupplier message, String sourceFile, Int sourceLine, + Attr[] extra = [], Exception? cause = Null) { + emit(level, message, extra, cause, sourceFile, sourceLine); + } + /** * Common emission path used by [log] and [logAt]. The handler owns any attributes * attached by derived loggers; the record receives only call-time extras. @@ -159,7 +201,28 @@ const Logger(Handler handler) time = clock.now, message = message, level = level, - attrs = extra.toArray(Constant), + attrs = Attr.resolveAll(extra), + exception = cause, + sourceFile = sourceFile, + sourceLine = sourceLine, + )); + } + + /** + * Lazy-message equivalent of [emit]. Both message and lazy attrs resolve only after + * the handler accepts the level, matching Go slog's `Enabled` fast path and + * `LogValuer`-style value resolution. + */ + private void emit(Level level, MessageSupplier message, Attr[] extra, Exception? cause, + String? sourceFile, Int sourceLine) { + if (!handler.enabled(level)) { + return; + } + handler.handle(new Record( + time = clock.now, + message = message(), + level = level, + attrs = Attr.resolveAll(extra), exception = cause, sourceFile = sourceFile, sourceLine = sourceLine, diff --git a/lib_slogging/src/test/x/SLoggingTest/LazyCounter.x b/lib_slogging/src/test/x/SLoggingTest/LazyCounter.x new file mode 100644 index 0000000000..29a7129216 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/LazyCounter.x @@ -0,0 +1,12 @@ +/** + * Service-backed counter for `Attr.lazy` tests. `Attr` is a `const`, so the supplier + * carrier is frozen; a service reference is passable while a mutable local counter is not. + */ +service LazyCounter { + public/private Int calls = 0; + + String value(String result) { + ++calls; + return result; + } +} diff --git a/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x new file mode 100644 index 0000000000..d407740365 --- /dev/null +++ b/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x @@ -0,0 +1,78 @@ +import slogging.Attr; +import slogging.Logger; + +/** + * Verifies slog-shaped lazy logging semantics. The logger must perform the handler + * enabled check before it invokes lazy message suppliers or `Attr.lazy` suppliers. + */ +class LazyLoggingTest { + + @Test + void shouldNotEvaluateLazyMessageWhenLevelDisabled() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + @Volatile Int calls = 0; + + handler.setLevel(slogging.Level.Info); + logger.debug(() -> { + ++calls; + return "expensive debug message"; + }); + + assert calls == 0; + assert handler.records.empty; + } + + @Test + void shouldEvaluateLazyMessageWhenEnabled() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + @Volatile Int calls = 0; + + logger.info(() -> { + ++calls; + return "expensive info message"; + }); + + assert calls == 1; + assert handler.records.size == 1; + assert handler.records[0].message == "expensive info message"; + } + + @Test + void shouldNotEvaluateLazyAttrWhenLevelDisabled() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + @Volatile Int calls = 0; + + handler.setLevel(slogging.Level.Info); + logger.debug("payload", [ + Attr.lazy("json", () -> { + ++calls; + return "expensive"; + }), + ]); + + assert calls == 0; + assert handler.records.empty; + } + + @Test + void shouldEvaluateLazyAttrWhenEnabled() { + ListHandler handler = new ListHandler(); + Logger logger = new Logger(handler); + LazyCounter counter = new LazyCounter(); + + logger.info("payload", [ + Attr.lazy("json", () -> { + return counter.value("expensive"); + }), + ]); + + assert counter.calls == 1; + assert handler.records.size == 1; + assert handler.records[0].attrs.size == 1; + assert handler.records[0].attrs[0].key == "json"; + assert handler.records[0].attrs[0].value.as(String) == "expensive"; + } +} From a426d8a7e5d855f0671eb5cbc6b49d4bcc96fbfe Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 11:51:57 +0200 Subject: [PATCH 21/28] Split logging manual scenarios by API style --- doc/logging/README.md | 25 ++- doc/logging/lib-logging-vs-lib-slogging.md | 13 +- doc/logging/open-questions.md | 4 +- doc/logging/usage/injected-logger-example.md | 18 +- lib_logging/src/test/x/LoggingTest/MDCTest.x | 2 +- manualTests/src/main/x/TestLogger.x | 151 ------------- manualTests/src/main/x/TestLogging.x | 212 +++++++++++++++++++ manualTests/src/main/x/TestSLogging.x | 179 ++++++++++++++++ 8 files changed, 428 insertions(+), 176 deletions(-) delete mode 100644 manualTests/src/main/x/TestLogger.x create mode 100644 manualTests/src/main/x/TestLogging.x create mode 100644 manualTests/src/main/x/TestSLogging.x diff --git a/doc/logging/README.md b/doc/logging/README.md index 8f0ccd6ddf..f869750312 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -13,9 +13,10 @@ POC should either disappear or become an explicitly named adapter/comparison mod There is no intended long-term build mode where the XDK is compiled "with logging" or "with slogging" as alternate mutually exclusive standard libraries. For this POC, the practical verification path is to build both `lib_logging` and `lib_slogging`, then run -the manual injection demo that exercises both `@Inject logging.Logger logger;` and -`@Inject slogging.Logger logger;`. The runtime wiring is deliberately tolerant while -the comparison exists: if only one of the POC modules is on a runner's module path, the +the side-by-side manual demos: `manualTests/TestLogging.x` exercises +`@Inject logging.Logger logger;`, while `manualTests/TestSLogging.x` exercises +`@Inject slogging.Logger logger;`. The runtime wiring is deliberately tolerant while the +comparison exists: if only one of the POC modules is on a runner's module path, the native container should register only that module's injected resources instead of aborting startup. After review, the chosen API should be the only first-class `lib_logging` facade. @@ -80,8 +81,8 @@ to JSON/cloud sinks without parsing message text. | Library | Prior art | Current proof | |---|---|---| -| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 70 focused XTC test methods, injected manual demo, async/composite/hierarchical/JSON backend building blocks. | -| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 41 focused XTC test methods, injected/manual coverage, async handler, lazy attrs, handler options, and JSON/redaction support. | +| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 70 focused XTC test methods, dedicated injected manual demo, async/composite/hierarchical/JSON backend building blocks. | +| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 41 focused XTC test methods, dedicated injected manual demo, async handler, lazy attrs, handler options, and JSON/redaction support. | ## Recommendation @@ -205,12 +206,14 @@ lib_slogging/ slog-shaped sibling library ## Status -**Both libraries are intended to compile and pass tests.** End-to-end demo runs in -`manualTests/TestLogger.x` and exercises both injected logger types on the -interpreter side. Runtime fallback naming for injected `logging.Logger` now derives -from the caller namespace when the compiler only supplies the default field name -`"logger"`. Explicit source metadata is available through `Logger.logAt(...)`; -automatic compiler call-site capture remains the next compiler/runtime polish step. +**Both libraries are intended to compile and pass tests.** End-to-end demos run in +`manualTests/TestLogging.x` and `manualTests/TestSLogging.x`, so reviewers can compare +the SLF4J-shaped and slog-shaped APIs side by side while still exercising real +`@Inject Logger logger;` acquisition. Runtime fallback naming for injected +`logging.Logger` now derives from the caller namespace when the compiler only supplies +the default field name `"logger"`. Explicit source metadata is available through +`Logger.logAt(...)`; automatic compiler call-site capture remains the next +compiler/runtime polish step. Tier 3 backend primitives have landed in the base libraries. A full configuration-file loader and rolling-file/network destinations are deliberately not diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index df172afe57..d09224312c 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -10,10 +10,11 @@ shape should graduate as `lib_logging`; `lib_slogging` is a POC name for the Go The branch does not introduce a clean XDK build switch between "logging" and "slogging". During the POC, the meaningful validation is to compile both libraries and -run the manual injection test that uses both injected logger types. Runners that only -carry one POC module should still start; the native injector should simply expose the -resources for whichever logging module is present. That tolerance is a temporary -comparison aid, not a statement that both APIs should remain in the XDK. +run the two manual injection tests: `TestLogging` for the SLF4J-shaped API and +`TestSLogging` for the slog-shaped API. Runners that only carry one POC module should +still start; the native injector should simply expose the resources for whichever +logging module is present. That tolerance is a temporary comparison aid, not a +statement that both APIs should remain in the XDK. | Library | Prior art | Core shape | |---|---|---| @@ -33,9 +34,9 @@ Go `log/slog` counterpart are in [`api-cross-reference.md`](api-cross-reference. > Status note: both libraries are working comparison POCs. `lib_logging` is the > recommended canonical facade and has the fuller SLF4J surface (70 focused XTC test -> methods plus the injected manual demo), including async/composite/hierarchical/JSON +> methods plus the dedicated injected manual demo), including async/composite/hierarchical/JSON > backend building blocks. `lib_slogging` has the more compact slog surface (41 focused -> XTC test methods plus injected/manual coverage), with runtime injection, async +> XTC test methods plus the dedicated injected manual demo), with runtime injection, async > handler support, `lib_json` JSON rendering, redaction options, source metadata, > context binding, and handler derivation semantics implemented. diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index be08f2e84c..4d423b9d7b 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -165,7 +165,7 @@ permanent default facade. ### Q-D7. Cross-module default-argument resolution on `const` constructors -Calling `new ConsoleLogSink()` from the `TestLogger` module fails with +Calling `new ConsoleLogSink()` from the `TestLogging` module fails with `Unresolvable function "void construct()"` even though `ConsoleLogSink` was declared as `const ConsoleLogSink(Level rootLevel = Info)`. Same shape failed for `new BasicLogger(name, sink)` against @@ -246,7 +246,7 @@ review have been closed as follows: | # | Item | Notes | |---|---|---| -| S-1 | Runtime injection for `@Inject slogging.Logger logger;` | **Done.** `NativeContainer` now resolves resources by `(name, requested type)`, so `logging.Logger logger` and `slogging.Logger logger` can both use the default name. `manualTests/TestLogger.x` exercises both. | +| S-1 | Runtime injection for `@Inject slogging.Logger logger;` | **Done.** `NativeContainer` now resolves resources by `(name, requested type)`, so `logging.Logger logger` and `slogging.Logger logger` can both use the default name. `manualTests/TestLogging.x` and `manualTests/TestSLogging.x` exercise the two injected APIs separately. | | S-2 | Production JSON handler | **Done for the base POC.** `JSONHandler` renders through `lib_json`, escapes strings through `Printer`, preserves nested groups, emits source metadata, renders exceptions structurally, and honors `HandlerOptions` redaction/field-name/source knobs. Output destinations beyond `Console` remain a future handler family. | | S-3 | Source-location capture | **Done via explicit API.** `Logger.logAt(level, message, sourceFile, sourceLine, attrs, cause)` populates `Record.sourceFile` / `sourceLine`. Automatic compiler/runtime call-site capture remains a future enhancement. | | S-4 | Context story | **Done.** `LoggerContext` wraps `SharedContext` for framework/request propagation. Explicit logger passing remains the recommended default. | diff --git a/doc/logging/usage/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md index ce20fc9ae1..326b461948 100644 --- a/doc/logging/usage/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -4,8 +4,17 @@ This document shows the canonical way an Ecstasy application uses `lib_logging`. goal is to be the file someone reads when they ask "okay, but what does it actually look like in real code?" -The accompanying executable sample lives at -[`manualTests/src/main/x/TestLogger.x`](../../../manualTests/src/main/x/TestLogger.x). +The accompanying executable SLF4J-shaped sample lives at +[`manualTests/src/main/x/TestLogging.x`](../../../manualTests/src/main/x/TestLogging.x). +The slog-shaped comparison sample is +[`manualTests/src/main/x/TestSLogging.x`](../../../manualTests/src/main/x/TestSLogging.x). + +Run them side by side with: + +```shell +./gradlew :manualTests:runOne -PtestName=TestLogging --console=plain +./gradlew :manualTests:runOne -PtestName=TestSLogging --console=plain +``` > **Status (2026-05).** Runtime injection of `@Inject Logger logger;` is wired for > the manual interpreter demo. The injected value is a `BasicLogger`, so per-fiber @@ -54,9 +63,8 @@ module HelloLogging { ``` That's it. There is no factory call, no static initializer, no configuration file. The -runtime container hands the application a `Logger` (named `"logger"` for now — see -Stage 4 of the runtime plan for the optional compiler-side default-name change); the -default sink (`ConsoleLogSink`) emits the line to the platform `Console`. Output: +runtime container hands the application a `Logger`; the default sink (`ConsoleLogSink`) +emits the line to the platform `Console`. Output: ``` 2026-04-29T11:23:45.012Z [] INFO logger: hello, world diff --git a/lib_logging/src/test/x/LoggingTest/MDCTest.x b/lib_logging/src/test/x/LoggingTest/MDCTest.x index 716ee0cc10..75df2706e7 100644 --- a/lib_logging/src/test/x/LoggingTest/MDCTest.x +++ b/lib_logging/src/test/x/LoggingTest/MDCTest.x @@ -7,7 +7,7 @@ import logging.MDC; * and the snapshot-on-emit path that lands MDC contents on `LogEvent.mdcSnapshot`. * * Per-fiber isolation across spawned services is exercised end-to-end by the manualTests - * `TestLogger.x` demo; that path needs runtime injection and a spawned service, which is + * `TestLogging.x` demo; that path needs runtime injection and a spawned service, which is * outside the scope of these in-process unit tests. * * Each test calls `mdc.clear()` at the start because the test harness does not reset MDC diff --git a/manualTests/src/main/x/TestLogger.x b/manualTests/src/main/x/TestLogger.x deleted file mode 100644 index 3259640f48..0000000000 --- a/manualTests/src/main/x/TestLogger.x +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Manual test that exercises lib_logging end-to-end. - * - * The unit tests under `lib_logging/src/test/x/LoggingTest/` pin down the API surface by - * constructing `BasicLogger` directly against an in-memory sink. That works fine for - * verifying the API, but it does not exercise the **runtime injection** path - * (`@Inject Logger logger;`) or the per-name derivation pattern - * (`logger.named("MyService")`) — both of which depend on runtime wiring. This module - * drives those paths explicitly. - * - * The three sections of `run()` line up one-for-one with the three patterns user code - * is allowed to use: - * - `runDirect` — explicit `new BasicLogger(...)`. Works without the runtime. - * - `runInjected` — `@Inject Logger logger;`. Resolves to the default logger. - * - `runInjectedByName` — `@Inject Logger logger; Logger named = logger.named(...)`. - * Per-name derivation, the SLF4J `getLogger(class)` analogue. - */ -module TestLogger { - package log import logging.xtclang.org; - package slog import slogging.xtclang.org; - import log.BasicLogger; - import log.BasicMarker; - import log.ConsoleLogSink; - import log.Logger; - import log.LogSink; - import log.Marker; - import log.MDC; - import slog.Attr as SAttr; - import slog.Logger as SLogger; - - @Inject Console console; - - void run() { - console.print("--- runDirect (no injection) ---"); - runDirect(); - - console.print("--- runInjected (default logger via @Inject) ---"); - runInjected(); - - console.print("--- runInjectedByName (per-name via Logger.named) ---"); - runInjectedByName(); - - console.print("--- runMDC (per-fiber context) ---"); - runMDC(); - - console.print("--- runInjectedSlog (slog-shaped logger via @Inject) ---"); - runInjectedSlog(); - } - - /** - * Drives the library by constructing a `BasicLogger` over a `ConsoleLogSink` - * explicitly. Works today. - */ - void runDirect() { - LogSink sink = new ConsoleLogSink(); - Logger logger = new BasicLogger("TestLogger.direct", sink); - - logger.info ("hello, {}", ["world"]); - logger.warn ("disk space low: {} MB", [42]); - - try { - failingOperation(); - } catch (Exception e) { - logger.error("operation failed", cause=e); - } - - if (logger.debugEnabled) { - logger.debug("this won't appear at the default Info threshold"); - } - - // Marker. - Marker audit = new BasicMarker("AUDIT"); - logger.info("user signed in", marker=audit); - - // Fluent builder. - logger.atInfo() - .addMarker(audit) - .addKeyValue("requestId", "r_42") - .log("payment processed"); - } - - /** - * Default-logger injection. The runtime registers the familiar `"logger"` resource - * name and, for canonical `logging.Logger`, falls back to a BasicLogger named from - * the caller namespace when the compiler supplied only that default field name. - */ - void runInjected() { - @Inject Logger logger; - logger.info("hello from the injected logger"); - } - - /** - * Per-name logger via `Logger.named(String)`. Mirrors SLF4J's - * `LoggerFactory.getLogger(MyClass.class)` idiom: inject the default logger once, - * derive named children at the call site (or store them as fields on the enclosing - * class). The derived logger shares this logger's sink, so all configuration applied - * to the default logger flows through to its descendants. - * - * Bare-essentials demo target: the message must print as `hello world` (formatted by - * `MessageFormatter`), not `hello {}` (raw). The logger-name column on the resulting - * line should read `Demo`. - */ - void runInjectedByName() { - @Inject Logger logger; - Logger demo = logger.named("Demo"); - demo.info("hello {}", ["world"]); - } - - void failingOperation() { - throw new Exception("boom"); - } - - /** - * MDC end-to-end via runtime injection. Demonstrates that: - * - `@Inject MDC` resolves through the native supplier; - * - `mdc.put` bindings appear on subsequent log lines via `[mdc=…]`; - * - `mdc.remove` and `mdc.clear` take effect from the next emission onward. - */ - void runMDC() { - @Inject Logger logger; - @Inject MDC mdc; - - mdc.put("requestId", "r_42"); - mdc.put("user", "u_7"); - console.print($" [diagnostic] mdc.copyOfContextMap = {mdc.copyOfContextMap}"); - - // (A) Through the runtime-injected BasicLogger const. This is intentionally - // not a service wrapper; keeping the call on this fiber is what makes MDC - // visible to BasicLogger.emit(). - logger.info("via @Inject Logger (BasicLogger const)"); - - // (B) Through a directly-constructed BasicLogger (no service wrapper). - Logger direct = new BasicLogger("MDC.direct", new ConsoleLogSink()); - direct.info("via direct BasicLogger"); - - mdc.clear(); - } - - /** - * Runtime injection for the slog-shaped library. This proves that the native - * injector can resolve the same resource name (`logger`) by requested type: - * `logging.Logger` and `slogging.Logger` are both injectable. - */ - void runInjectedSlog() { - @Inject SLogger logger; - - SLogger payments = logger.with([SAttr.of("requestId", "r_slog")]) - .withGroup("payments"); - payments.info("charged", [SAttr.of("amount", 1099)]); - } -} diff --git a/manualTests/src/main/x/TestLogging.x b/manualTests/src/main/x/TestLogging.x new file mode 100644 index 0000000000..bfc305cf25 --- /dev/null +++ b/manualTests/src/main/x/TestLogging.x @@ -0,0 +1,212 @@ +/** + * Manual end-to-end scenario for the SLF4J-shaped `lib_logging` POC. + * + * The unit tests prove the individual pieces; this module is the executable story a + * reviewer can run to see the real injection path and the major API features together: + * injected logger/MDC, named loggers, formatting, markers, lazy suppliers, fluent + * structured events, explicit source metadata, and Logback-style backend primitives. + */ +module TestLogging { + package log import logging.xtclang.org; + + import log.AsyncLogSink; + import log.BasicLogger; + import log.CompositeLogSink; + import log.ConsoleLogSink; + import log.HierarchicalLogSink; + import log.JsonLogSink; + import log.JsonLogSinkOptions; + import log.Level; + import log.LogEvent; + import log.Logger; + import log.LoggerRegistry; + import log.LogSink; + import log.Marker; + import log.MarkerFactory; + import log.MDC; + import log.MemoryLogSink; + import log.NoopLogSink; + + @Inject Console console; + + void run() { + console.print("=== TestLogging: SLF4J-shaped logging ==="); + runInjectedLogger(); + runMdcAndNamedLogger(); + runMarkersAndFluentBuilder(); + runBackendPrimitives(); + runRegistry(); + } + + /** + * Real runtime injection of `logging.Logger`. The default injected logger uses the + * familiar resource name `logger` and is backed by `ConsoleLogSink`. + */ + void runInjectedLogger() { + console.print("--- injected Logger: levels, formatting, lazy messages ---"); + + @Inject Logger logger; + assert logger.infoEnabled; + assert !logger.debugEnabled; + + logger.info("hello, {}", ["world"]); + logger.warn("disk space low: {} MB", [42]); + + @Volatile Int lazyCalls = 0; + logger.debug(() -> { + ++lazyCalls; + return "this debug message must not be built"; + }); + logger.atDebug() + .addLazyArgument(() -> { + ++lazyCalls; + return "this debug argument must not be built"; + }) + .log("debug {}"); + assert lazyCalls == 0; + + logger.info(() -> { + ++lazyCalls; + return "lazy info message built after the Info check"; + }); + assert lazyCalls == 1; + + try { + failingOperation(); + } catch (Exception e) { + logger.error("operation failed for {}", ["manual"], cause=e); + } + + logger.log(Level.Warn, "runtime-selected level {}", ["Warn"]); + logger.logAt(Level.Info, () -> "source-aware lazy message", + "TestLogging.x", 74); + } + + /** + * Injected MDC plus `Logger.named(...)`, the Ecstasy equivalent of deriving a + * per-class/per-component SLF4J logger. + */ + void runMdcAndNamedLogger() { + console.print("--- injected MDC and named logger ---"); + + @Inject Logger logger; + @Inject MDC mdc; + + Logger payments = logger.named("manual.logging.payments"); + + mdc.put("requestId", "r_manual"); + mdc.put("user", "u_7"); + payments.info("handling payment {}", ["p_123"]); + + assert mdc.copyOfContextMap.getOrNull("requestId") == "r_manual"; + + mdc.remove("user"); + assert !mdc.copyOfContextMap.contains("user"); + mdc.clear(); + } + + /** + * Marker DAGs and the SLF4J 2.x fluent builder against a memory sink so the manual + * scenario asserts the payload, not just the console output. + */ + void runMarkersAndFluentBuilder() { + console.print("--- markers and fluent structured event builder ---"); + + MemoryLogSink sink = new MemoryLogSink(); + Logger logger = new BasicLogger("manual.logging.fluent", sink); + MarkerFactory markers = new MarkerFactory(); + Marker audit = markers.getMarker("AUDIT"); + Marker security = markers.getMarker("SECURITY"); + Exception cause = new Exception("manual cause"); + + security.add(audit); + assert markers.exists("AUDIT"); + assert security.contains(audit); + + logger.atInfo() + .addMarker(security) + .setCause(cause) + .addArgument("checkout") + .addLazyArgument(() -> "lazy-arg") + .addKeyValue("requestId", "r_fluent") + .addLazyKeyValue("payload", () -> "lazy-payload") + .log("operation {} {}"); + + assert sink.events.size == 1; + LogEvent event = sink.events[0]; + assert event.level == Level.Info; + assert event.message == "operation checkout lazy-arg"; + assert event.marker?.contains(audit) : assert; + assert event.exception == cause; + assert event.arguments[1].as(String) == "lazy-arg"; + assert event.keyValues.getOrNull("requestId").as(String) == "r_fluent"; + assert event.keyValues.getOrNull("payload").as(String) == "lazy-payload"; + } + + /** + * The backend layer that gives this SLF4J-shaped facade Logback-style operational + * power: fanout, hierarchical level config, async wrapping, JSON/redaction, and nop. + */ + void runBackendPrimitives() { + console.print("--- Logback-style backend primitives ---"); + + MemoryLogSink first = new MemoryLogSink(); + MemoryLogSink second = new MemoryLogSink(); + Logger fanout = new BasicLogger("manual.logging.fanout", + new CompositeLogSink([first, second])); + fanout.warn("fanout event"); + assert first.events.size == 1; + assert second.events.size == 1; + + MemoryLogSink hierarchicalTarget = new MemoryLogSink(); + HierarchicalLogSink hierarchy = new HierarchicalLogSink( + hierarchicalTarget, Level.Warn); + hierarchy.setLevel("manual.logging.debug", Level.Debug); + + Logger muted = new BasicLogger("manual.logging.other.Component", hierarchy); + Logger debug = new BasicLogger("manual.logging.debug.Component", hierarchy); + muted.info("hidden by root Warn level"); + debug.debug("visible through prefix Debug override"); + assert hierarchicalTarget.events.size == 1; + assert hierarchicalTarget.events[0].message == "visible through prefix Debug override"; + + MemoryLogSink asyncTarget = new MemoryLogSink(); + AsyncLogSink async = new AsyncLogSink(asyncTarget, 8); + Logger asyncLogger = new BasicLogger("manual.logging.async", async); + asyncLogger.info("async event"); + async.flush(); + assert asyncTarget.events.size == 1; + + Logger jsonLogger = new BasicLogger("manual.logging.json", + new JsonLogSink(new JsonLogSinkOptions(Level.Debug, ["secret"]))); + jsonLogger.atInfo() + .addKeyValue("secret", "redact-me") + .addKeyValue("visible", "ok") + .log("json sink redaction demo"); + + Logger noop = new BasicLogger("manual.logging.noop", new NoopLogSink()); + assert !noop.errorEnabled; + noop.error("dropped by no-op sink"); + } + + /** + * Name-keyed logger interning, corresponding to SLF4J `ILoggerFactory` semantics. + */ + void runRegistry() { + console.print("--- logger registry interning ---"); + + LoggerRegistry registry = new LoggerRegistry(new MemoryLogSink()); + Logger one = registry.ensure("manual.logging.registry"); + Logger two = registry.ensure("manual.logging.registry"); + Logger child = one.named("manual.logging.registry.child"); + Logger child2 = registry.ensure("manual.logging.registry.child"); + + assert &one == &two; + assert &child == &child2; + child.info("registry child event"); + } + + void failingOperation() { + throw new Exception("boom"); + } +} diff --git a/manualTests/src/main/x/TestSLogging.x b/manualTests/src/main/x/TestSLogging.x new file mode 100644 index 0000000000..fc3a4c1fce --- /dev/null +++ b/manualTests/src/main/x/TestSLogging.x @@ -0,0 +1,179 @@ +/** + * Manual end-to-end scenario for the Go `log/slog`-shaped `lib_slogging` POC. + * + * Run this next to `TestLogging` to compare the two proposed call shapes. This file + * exercises injected slog logger acquisition, attrs, derived loggers, groups, lazy + * message/attr suppliers, explicit source metadata, context binding, custom levels, + * and the handler backend SPI. + */ +module TestSLogging { + package slog import slogging.xtclang.org; + + import slog.AsyncHandler; + import slog.Attr; + import slog.HandlerOptions; + import slog.JSONHandler; + import slog.Level; + import slog.Logger; + import slog.LoggerContext; + import slog.MemoryHandler; + import slog.NopHandler; + import slog.Record; + import slog.TextHandler; + + @Inject Console console; + + void run() { + console.print("=== TestSLogging: slog-shaped logging ==="); + runInjectedLogger(); + runDerivedLoggersAndLazyAttrs(); + runHandlerPrimitives(); + runContextBinding(); + } + + /** + * Real runtime injection of `slogging.Logger`. The same resource name `logger` + * works because the injector also keys by requested type. + */ + void runInjectedLogger() { + console.print("--- injected slog Logger: levels, attrs, lazy messages ---"); + + @Inject Logger logger; + assert logger.infoEnabled; + assert !logger.debugEnabled; + + logger.info("hello", [Attr.of("style", "slog")]); + + @Volatile Int lazyCalls = 0; + logger.debug(() -> { + ++lazyCalls; + return "this debug message must not be built"; + }); + assert lazyCalls == 0; + + logger.info(() -> { + ++lazyCalls; + return "lazy info message built after the Info check"; + }, [Attr.of("scenario", "injected")]); + assert lazyCalls == 1; + + try { + failingOperation(); + } catch (Exception e) { + logger.error("operation failed", [Attr.of("operation", "manual")], cause=e); + } + + Level notice = new Level(2, "NOTICE"); + logger.log(notice, "custom level", [Attr.of("severity", notice.severity)]); + logger.logAt(Level.Warn, () -> "source-aware lazy message", + "TestSLogging.x", 64, [Attr.of("source", "explicit")]); + } + + /** + * The central slog idea: derive loggers with attrs/groups instead of named logger + * categories and MDC. Also exercises `Attr.lazy`, the POC equivalent of Go + * `LogValuer`. + */ + void runDerivedLoggersAndLazyAttrs() { + console.print("--- derived loggers, groups, and lazy attrs ---"); + + MemoryHandler capture = new MemoryHandler(); + Logger root = new Logger(capture); + LazyCounter counter = new LazyCounter(); + + Logger payments = root.with([ + Attr.of ("requestId", "r_slog"), + Attr.lazy("bound", () -> counter.value("bound-value")), + ]).withGroup("payments"); + + payments.info("charged", [ + Attr.lazy ("payload", () -> counter.value("payload-value")), + Attr.group("card", [Attr.of("last4", "4242")]), + Attr.of ("amount", 1099), + ]); + + assert counter.calls == 2; + assert capture.records.size == 1; + + Record record = capture.records[0]; + assert record.message == "charged"; + assert record.attrs[0].key == "requestId"; + assert record.attrs[1].key == "bound"; + assert record.attrs[1].value.as(String) == "bound-value"; + assert record.attrs[2].key == "payments"; + + Attr[] paymentAttrs = record.attrs[2].value.as(Attr[]); + assert paymentAttrs[0].key == "payload"; + assert paymentAttrs[0].value.as(String) == "payload-value"; + assert paymentAttrs[1].key == "card"; + assert paymentAttrs[2].key == "amount"; + } + + /** + * Handler-side equivalents of Logback-style backend composition: memory capture, + * async wrapping, text/JSON rendering, redaction, and no-op output. + */ + void runHandlerPrimitives() { + console.print("--- slog handler primitives ---"); + + MemoryHandler asyncTarget = new MemoryHandler(); + AsyncHandler async = new AsyncHandler(asyncTarget, 8); + Logger asyncLogger = new Logger(async); + asyncLogger.info("async record", [Attr.of("queued", True)]); + async.flush(); + assert asyncTarget.records.size == 1; + + Logger textLogger = new Logger(new TextHandler(Level.Debug)); + textLogger.debug("text handler debug", [Attr.of("visible", True)]); + + Logger jsonLogger = new Logger(new JSONHandler( + new HandlerOptions(Level.Debug, ["secret"]))); + jsonLogger.info("json handler redaction", [ + Attr.of("secret", "redact-me"), + Attr.of("visible", "ok"), + ]); + + Logger noop = new Logger(new NopHandler()); + assert !noop.errorEnabled; + noop.error("dropped by no-op handler"); + } + + /** + * Optional implicit context helper for framework/request propagation. + */ + void runContextBinding() { + console.print("--- LoggerContext binding ---"); + + MemoryHandler capture = new MemoryHandler(); + Logger root = new Logger(capture); + LoggerContext context = new LoggerContext(); + Logger bound = root.with([Attr.of("context", "bound")]); + + using (context.bind(bound)) { + Logger current = context.currentOr(root); + current.info("from bound context"); + } + + assert capture.records.size == 1; + assert capture.records[0].attrs[0].key == "context"; + assert capture.records[0].attrs[0].value.as(String) == "bound"; + } + + void failingOperation() { + throw new Exception("boom"); + } + + /** + * Service-backed lazy counter. `Attr.lazy` stores the supplier in a `const` attr, so + * captured mutable state must be passable; a service reference is the right manual + * test stand-in for a real provider object. + */ + service LazyCounter { + public/private Int calls = 0; + + String value(String result) { + ++calls; + return result; + } + } +} From 9ee42571757cd156c1cc6834a5d8c15c3c9e8c90 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 12:55:39 +0200 Subject: [PATCH 22/28] Address logging POC review: doc restructure + 8 code fixes Documentation: - README opens with hello-world; POC governance moved below the fold. - Split Goals / Requirements / Non-goals; Requirements gain a Status column with links to evidence (tests/files). - Add roadmap.md (merge-blocking -> v1 -> future). - Hoist marker explainer to concepts/markers.md (~280 lines moved out of lib-logging-vs-lib-slogging.md, replaced with a short summary + link). - Add prev/next/up footers along the canonical reading path. - Shrink lib_logging/README.md and lib_slogging/README.md to module-card size; defer to doc/logging/README.md as single source of truth. - One-line audience tag at the top of every leaf doc. Code: - AsyncLogSink/AsyncHandler.drain: try/finally so 'draining' cannot be stranded by a delegate exception; per-event try/catch contains failures. Swap-out batch replaces O(n) queue.delete(0) per event. - NativeContainer.getInjectable: drop asymmetric isCanonicalLoggingLogger fallback (lib_slogging had no equivalent); restore O(1) name-indexed lookup via Map>. - javatools_bridge: drop unused xtcModule(libs.xdk.logging) (_native.x doesn't reference it). - BasicEventBuilder: collapse parallel lazyArgs/lazyKeyValues boolean arrays into discrimination via a new LazyValue wrapper. The wrapper is a class (not const) so captured supplier closures can mutate locals; the slog twin's LazyValue stays const because Attr (its host) is const. Both files cross-reference each other's "Why class/const" rationale. - MarkerFactory: explicit thread-safety contract in the doc-comment ("class, not service" with the BasicMarker freeze interaction spelled out). A future service MarkerRegistry is on roadmap.md. - MDC.put: skip allocation when current value already matches. Verification: ./gradlew build (250 tasks); 70/70 lib_logging tests, 41/41 lib_slogging tests, TestLogging + TestSLogging manual demos. --- doc/logging/README.md | 235 ++++++------- doc/logging/api-cross-reference.md | 5 +- doc/logging/cloud-integration.md | 3 +- doc/logging/concepts/markers.md | 299 +++++++++++++++++ doc/logging/design/design.md | 5 +- doc/logging/design/why-slf4j-and-injection.md | 5 +- doc/logging/design/xdk-alignment.md | 5 +- doc/logging/future/logback-integration.md | 5 +- doc/logging/lib-logging-vs-lib-slogging.md | 311 +----------------- doc/logging/open-questions.md | 5 +- doc/logging/roadmap.md | 73 ++++ doc/logging/usage/configuration.md | 6 +- doc/logging/usage/custom-handlers.md | 5 +- doc/logging/usage/custom-sinks.md | 5 +- doc/logging/usage/ecstasy-vs-java-examples.md | 5 +- doc/logging/usage/injected-logger-example.md | 4 +- doc/logging/usage/lazy-logging.md | 4 +- .../usage/platform-and-examples-adaptation.md | 5 +- doc/logging/usage/slf4j-parity.md | 5 +- doc/logging/usage/slog-parity.md | 5 +- doc/logging/usage/structured-logging.md | 5 +- .../java/org/xvm/runtime/NativeContainer.java | 62 ++-- javatools_bridge/build.gradle.kts | 1 - lib_logging/README.md | 41 +-- lib_logging/src/main/x/logging/AsyncLogSink.x | 44 ++- .../src/main/x/logging/BasicEventBuilder.x | 77 +++-- lib_logging/src/main/x/logging/LazyValue.x | 36 ++ lib_logging/src/main/x/logging/MDC.x | 14 +- .../src/main/x/logging/MarkerFactory.x | 47 ++- lib_slogging/README.md | 53 +-- .../src/main/x/slogging/AsyncHandler.x | 32 +- 31 files changed, 799 insertions(+), 608 deletions(-) create mode 100644 doc/logging/concepts/markers.md create mode 100644 doc/logging/roadmap.md create mode 100644 lib_logging/src/main/x/logging/LazyValue.x diff --git a/doc/logging/README.md b/doc/logging/README.md index f869750312..6d9c486ea8 100644 --- a/doc/logging/README.md +++ b/doc/logging/README.md @@ -1,123 +1,113 @@ # Ecstasy logging — start here -This branch (`lagergren/logging`) adds two parallel logging libraries to the XDK -so reviewers can compare two familiar industry shapes in real Ecstasy code. The -branch now recommends `lib_logging` as the canonical API shape and keeps -`lib_slogging` as the comparison/reference POC. - -The two libraries coexist **only for this POC**. Application and XDK code should use -one logging shape or the other, not both in the same design. Once the XDK settles on an -injectable logging model, the winning API should be the XDK's `lib_logging`; the losing -POC should either disappear or become an explicitly named adapter/comparison module. - -There is no intended long-term build mode where the XDK is compiled "with logging" or -"with slogging" as alternate mutually exclusive standard libraries. For this POC, the -practical verification path is to build both `lib_logging` and `lib_slogging`, then run -the side-by-side manual demos: `manualTests/TestLogging.x` exercises -`@Inject logging.Logger logger;`, while `manualTests/TestSLogging.x` exercises -`@Inject slogging.Logger logger;`. The runtime wiring is deliberately tolerant while the -comparison exists: if only one of the POC modules is on a runner's module path, the -native container should register only that module's injected resources instead of -aborting startup. After review, the chosen API should be the only first-class -`lib_logging` facade. - -## First-pass summary - -Read this file first. It gives the whole design without requiring any deep dive: - -1. Application code asks for a logger with `@Inject Logger logger;`. -2. The injected logger is a small facade. Caller code logs messages, exceptions, - structured fields, markers/categories, and request context. -3. The host/container owns the backend. It can replace the active `LogSink` or `Handler` - with console, JSON, async, fanout, hierarchical-level, file, cloud, or test capture - implementations without changing application code. -4. `lib_logging` is the recommended canonical shape because it gives Ecstasy the - SLF4J/Logback model most JVM users expect: named loggers, `{}` formatting, markers, - MDC, fluent event builders, and a narrow `LogSink` backend SPI. -5. `lib_slogging` exists to compare the Go `log/slog` model: one structured carrier - (`Attr`), derived loggers via `with(...)`, open integer levels, grouped attrs, and a - `Handler` backend SPI. -6. The POC already implements both facades, injection, JSON output, redaction knobs, - async wrappers, memory test capture, and backend extension points. Production config - loading, rolling files, provider-specific cloud clients, automatic source capture, and - compiler-generated logger names are follow-up modules/runtime work. - -For a first review pass, read this README and then the top summaries of -[`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md), -[`usage/structured-logging.md`](usage/structured-logging.md), and -[`usage/configuration.md`](usage/configuration.md). Those documents keep their long -rationale and implementation details below the fast path. +`lib_logging` is the XDK's injectable logging library. Application code asks the +runtime for a logger and writes events; the host container chooses where the +output goes. -## Requirements +```ecstasy +@Inject Logger logger; +logger.info("processed {} records in {}ms", [count, elapsed]); +``` -The accepted XDK logging API **MUST** satisfy these requirements: - -- **One canonical facade:** there MUST be one injectable logging API, published as `lib_logging`. -- **Familiar call shape:** the call shape MUST be instantly recognizable to either SLF4J/Logback or slog users, - with migration examples for Java/Go teams. -- **Injection-first acquisition:** `@Inject Logger logger;` MUST let the host/container own - backend policy; library code MUST NOT choose global process logging. -- **Structured events:** events MUST carry first-class key/value fields, context fields, exceptions, - markers/categories where applicable, and JSON/cloud output without parsing messages. -- **Lazy disabled calls:** expensive message, argument, and structured-value construction - MUST be deferrable behind the level/marker check without requiring verbose caller-side - `if (logger.debugEnabled)` guards for every one-line log statement. -- **Dynamically pluggable backends:** sinks/handlers MUST be swappable, composable, wrappable - with async queues, and replaced by a host-controlled reload service. -- **Logback-equivalent operations:** the backend MUST support root level, per-logger/category overrides, - multi-destination fanout, async output, JSON/text formatting, redaction, and - configuration-file reload. -- **Production safety:** backend failures MUST NOT break callers; redaction, - output destinations, formatting knobs, and shutdown/flush behavior MUST be explicit. -- **Testability:** the API MUST provide in-memory capture sinks/handlers and contract tests for custom - backends. -- **Runtime/compiler path:** default logger naming and source-location capture MUST - have a clear lowering target even if full compiler sugar lands later. - -Both libraries use the same XDK-facing architecture: application code receives an -injected `Logger`, the container owns the backend, and production behavior is -provided by replacing a `LogSink` or `Handler`. Both can carry structured fields -to JSON/cloud sinks without parsing message text. - -| Library | Prior art | Current proof | -|---|---|---| -| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 70 focused XTC test methods, dedicated injected manual demo, async/composite/hierarchical/JSON backend building blocks. | -| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 41 focused XTC test methods, dedicated injected manual demo, async handler, lazy attrs, handler options, and JSON/redaction support. | +That is the whole hello-world. There is no factory call, no static initializer, +no configuration file. The default `ConsoleLogSink` writes the line; the host +can swap that for JSON, async, fanout, hierarchical levels, or a test capture +sink without changing caller code. -## Recommendation +> **New here?** Read [`usage/injected-logger-example.md`](usage/injected-logger-example.md) next — it walks +> from the smallest possible app up to a realistic per-class logger setup. Come +> back to this README for the design context. -Choose **`lib_logging`** as the canonical XDK logging facade. If reviewers choose the -slog shape instead, it should still graduate under the canonical `lib_logging` name; -`lib_slogging` is a POC name, not a proposed permanent sibling. The current -recommendation is the SLF4J-shaped implementation because it gives Ecstasy the -SLF4J/Logback-shaped surface that JVM users recognize immediately: named loggers, -`{}` formatting, markers, MDC, fluent event builders, and a `LogSink` backend -boundary. The slog-shaped library remains useful review material and an adapter -candidate, but keeping both indefinitely would recreate the logging fragmentation -these APIs were designed to avoid. +## Reading paths -## Reading Paths +| If you want to… | Read in this order | +|---|---| +| **Write code against `lib_logging`** | [`usage/injected-logger-example.md`](usage/injected-logger-example.md) → [`usage/structured-logging.md`](usage/structured-logging.md) → [`usage/lazy-logging.md`](usage/lazy-logging.md) | +| **Decide whether to merge this PR** | [`roadmap.md`](roadmap.md) → [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) → [`open-questions.md`](open-questions.md) | +| **Understand the API choice** | [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) → [`design/why-slf4j-and-injection.md`](design/why-slf4j-and-injection.md) → [`cloud-integration.md`](cloud-integration.md) | +| **Migrate from SLF4J** | [`usage/slf4j-parity.md`](usage/slf4j-parity.md) → [`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md) | +| **Migrate from Go `slog`** | [`usage/slog-parity.md`](usage/slog-parity.md) → [`api-cross-reference.md`](api-cross-reference.md) | +| **Write a custom backend** | [`usage/custom-sinks.md`](usage/custom-sinks.md) → [`design/design.md`](design/design.md) | +| **Read the architecture** | [`design/design.md`](design/design.md) → [`design/xdk-alignment.md`](design/xdk-alignment.md) | + +Each doc has a "Next →" footer that follows its column above. You can also use +the [Full doc index](#full-doc-index) at the bottom. + +## Goals + +Why this library exists. These are the directional aims, not contractual specs: + +- **Familiar.** A JVM or Go engineer should recognize the call shape on first + read, with no Ecstasy-specific learning curve for the common case. +- **Container-aware.** The host application — not the calling library — decides + where logs go. Per-container override is a one-line change, not a classpath + ceremony. +- **Cloud-ready.** Structured fields (level, message, request context, exception) + flow into JSON output and from there into Cloud Logging / CloudWatch / App + Insights through small, well-known adapters. See [`cloud-integration.md`](cloud-integration.md). +- **Pay-for-what-you-use.** Disabled log calls cost a level check. Expensive + message and value construction can be deferred behind that check. +- **Testable.** Capturing log output in a test should be one line, with no + global state to reset. -Start here, then follow only the path that matches your review: +## Requirements -1. **First-pass design:** this README. -2. **API choice:** [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md). -3. **Structured logging requirement:** [`usage/structured-logging.md`](usage/structured-logging.md). -4. **Lazy logging requirement:** [`usage/lazy-logging.md`](usage/lazy-logging.md). -5. **Configuration/backend story:** [`usage/configuration.md`](usage/configuration.md). -6. **Exact API mapping:** [`api-cross-reference.md`](api-cross-reference.md). +The narrower, verifiable list — what the accepted XDK logging API **must** do. +Status reflects what the POC in this branch ships: + +| # | Requirement | Status | Evidence | +|---|---|---|---| +| R1 | One canonical injectable logger acquired by `@Inject Logger logger;`. | ✓ done | [`Logger.x`](../../lib_logging/src/main/x/logging/Logger.x), [`NativeContainer.java`](../../javatools/src/main/java/org/xvm/runtime/NativeContainer.java) `registerLoggingResources` | +| R2 | Per-name child loggers via API, not via wildcard injection. | ✓ done | `Logger.named(String)` in [`Logger.x`](../../lib_logging/src/main/x/logging/Logger.x); [`NamedLoggerTest`](../../lib_logging/src/test/x/LoggingTest/NamedLoggerTest.x) | +| R3 | Events carry first-class structured key/value fields, exception, MDC, markers. | ✓ done | [`LogEvent.x`](../../lib_logging/src/main/x/logging/LogEvent.x); [`StructuredLoggingTest`](../../lib_logging/src/test/x/LoggingTest/StructuredLoggingTest.x) | +| R4 | Disabled log calls are cheap; expensive args/values can be supplied lazily. | ✓ done | `MessageSupplier`, `addLazyArgument` in [`LoggingEventBuilder.x`](../../lib_logging/src/main/x/logging/LoggingEventBuilder.x); [`LazyLoggingTest`](../../lib_logging/src/test/x/LoggingTest/LazyLoggingTest.x) | +| R5 | Backends are swappable, composable, async-wrappable, and test-capturable. | ✓ done | [`CompositeLogSink.x`](../../lib_logging/src/main/x/logging/CompositeLogSink.x), [`AsyncLogSink.x`](../../lib_logging/src/main/x/logging/AsyncLogSink.x), [`MemoryLogSink.x`](../../lib_logging/src/main/x/logging/MemoryLogSink.x) | +| R6 | Per-logger-name level routing (Logback longest-prefix). | ✓ done | [`HierarchicalLogSink.x`](../../lib_logging/src/main/x/logging/HierarchicalLogSink.x); [`HierarchicalLogSinkTest`](../../lib_logging/src/test/x/LoggingTest/HierarchicalLogSinkTest.x) | +| R7 | JSON output with redaction. | ✓ done | [`JsonLogSink.x`](../../lib_logging/src/main/x/logging/JsonLogSink.x), [`JsonLogSinkOptions.x`](../../lib_logging/src/main/x/logging/JsonLogSinkOptions.x); [`JsonLogSinkTest`](../../lib_logging/src/test/x/LoggingTest/JsonLogSinkTest.x) | +| R8 | Backend failures must not break callers. | ✓ contract | Documented in [`LogSink.x`](../../lib_logging/src/main/x/logging/LogSink.x); enforcement still up to each sink. | +| R9 | In-memory capture sink for tests. | ✓ done | [`MemoryLogSink.x`](../../lib_logging/src/main/x/logging/MemoryLogSink.x); used throughout `lib_logging/src/test/` | +| R10 | Default logger name and source-location capture have a stable lowering target the compiler can fill in later. | ~ partial | `Logger.logAt(...)` populates source explicitly; runtime fallback derives logger name from caller namespace. Compiler-synthesized source/name still TBD — see [`roadmap.md`](roadmap.md). | +| R11 | Configuration-file driven backend (Logback-equivalent ops). | → v1+ | Sketched in [`future/logback-integration.md`](future/logback-integration.md); not in this branch. | + +## Non-goals (this branch) + +These are explicitly out of scope for the POC. Listing them prevents reviewer +churn over things that were never the target: + +- **A configuration file format.** `JsonLogSinkOptions` covers the JSON sink's + knobs; XML/JSON/YAML config and hot reload belong to a future configured + backend. +- **Distributed tracing context propagation.** MDC carries strings; tracing + IDs can ride on it. Trace/span propagation itself is a separate library. +- **Automatic compiler call-site capture.** `Logger.logAt(sourceFile, sourceLine)` + is the lowering target; the compiler does not yet synthesize those args for + ordinary `logger.info(...)` calls. +- **Rolling/network/cloud destinations.** They are pure-XTC sink modules built + on the SPI; out of scope for the base library. + +## Two POCs in this branch + +This branch carries two parallel libraries so reviewers can compare API shapes +in real Ecstasy code: + +| Library | Prior art | Tests | +|---|---|---| +| [`lib_logging`](../../lib_logging/) | SLF4J 2.x + Logback | 70 XTC test methods, [`TestLogging.x`](../../manualTests/src/main/x/TestLogging.x) demo | +| [`lib_slogging`](../../lib_slogging/) | Go `log/slog` | 41 XTC test methods, [`TestSLogging.x`](../../manualTests/src/main/x/TestSLogging.x) demo | -The rest of the tree is organized by reader: +The branch **recommends `lib_logging`** as the canonical XDK shape on +familiarity and ecosystem grounds — the [API-choice doc](lib-logging-vs-lib-slogging.md) +explains why. Whichever shape wins should ship as `lib_logging`; `lib_slogging` +is a POC name, not a proposed permanent sibling. Both modules are deliberately +optional in the runtime: a build that only ships one of them must still start. -| Reader | Start with | Then read | -|---|---|---| -| Proposal reviewer | [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | [`api-cross-reference.md`](api-cross-reference.md), [`cloud-integration.md`](cloud-integration.md), [`open-questions.md`](open-questions.md) | -| SLF4J / Logback engineer | [`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md) | [`usage/slf4j-parity.md`](usage/slf4j-parity.md), [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | -| Go `log/slog` engineer | [`usage/slog-parity.md`](usage/slog-parity.md) | [`api-cross-reference.md`](api-cross-reference.md), [`lib_slogging` source](../../lib_slogging/src/main/x/slogging/) | -| XTC language designer | [`open-questions.md`](open-questions.md) | [`design/design.md`](design/design.md), [`design/xdk-alignment.md`](design/xdk-alignment.md) | -| Sink / handler author | [`usage/custom-sinks.md`](usage/custom-sinks.md), [`usage/custom-handlers.md`](usage/custom-handlers.md) | [`usage/structured-logging.md`](usage/structured-logging.md) | -| Cloud/backend reviewer | [`cloud-integration.md`](cloud-integration.md) | [`future/logback-integration.md`](future/logback-integration.md), [`usage/configuration.md`](usage/configuration.md) | -| Migration reviewer | [`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md) | [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | +## Status & roadmap + +The POC implements every requirement above except R11 (configuration-file +backend) and the compiler-side half of R10. The merge-blocking decisions, the +v1 scope, and the explicit-future items are tracked in +[`roadmap.md`](roadmap.md). Outstanding language/runtime questions for the XTC +designers are in [`open-questions.md`](open-questions.md) (Q-D1…Q-D7). ## Full doc index @@ -125,16 +115,19 @@ The rest of the tree is organized by reader: |---|---| | **Top-level** | | | [`README.md`](README.md) | This file. The "start here" entry point. | +| [`roadmap.md`](roadmap.md) | What blocks merge, what lands in v1, what is explicit future. | | [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) | API-choice summary first, then depth-first rationale on levels, context, markers, formatting, and backend shape. | | [`api-cross-reference.md`](api-cross-reference.md) | Official SLF4J / Go slog API links mapped to local Ecstasy types and difference notes. | | [`cloud-integration.md`](cloud-integration.md) | Why the API choice is the entry point to cloud observability ecosystems. | | [`open-questions.md`](open-questions.md) | Decision tracker: resolved calls, designer questions (Q-D1..Q-D7), W-item parity list, and remaining follow-up. | +| **Concepts** | | +| [`concepts/markers.md`](concepts/markers.md) | What a log marker is, who uses them, when they earn their API surface. | | **Design (`lib_logging` side)** | | | [`design/design.md`](design/design.md) | `lib_logging` architecture: types, API↔impl boundary, sink-type rule, MDC mechanism, per-container override. | | [`design/why-slf4j-and-injection.md`](design/why-slf4j-and-injection.md) | The original rationale for the SLF4J shape and injection-first acquisition. | | [`design/xdk-alignment.md`](design/xdk-alignment.md) | Alignment with the conventions of other XDK libraries (`lib_ecstasy`, `lib_cli`, etc.). | | **Usage / examples** | | -| [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | End-to-end working example of `@Inject Logger`. | +| [`usage/injected-logger-example.md`](usage/injected-logger-example.md) | End-to-end working example of `@Inject Logger`. **Suggested first stop for new users.** | | [`usage/ecstasy-vs-java-examples.md`](usage/ecstasy-vs-java-examples.md) | Per-feature SLF4J Java vs `lib_logging` Ecstasy. | | [`usage/slf4j-parity.md`](usage/slf4j-parity.md) | Exhaustive type/method mapping reference. | | [`usage/slog-parity.md`](usage/slog-parity.md) | Go `log/slog` vs `lib_slogging` mapping reference. | @@ -204,24 +197,6 @@ lib_slogging/ slog-shaped sibling library └── test/x/SLoggingTest/ 41 focused XTC test methods ``` -## Status - -**Both libraries are intended to compile and pass tests.** End-to-end demos run in -`manualTests/TestLogging.x` and `manualTests/TestSLogging.x`, so reviewers can compare -the SLF4J-shaped and slog-shaped APIs side by side while still exercising real -`@Inject Logger logger;` acquisition. Runtime fallback naming for injected -`logging.Logger` now derives from the caller namespace when the compiler only supplies -the default field name `"logger"`. Explicit source metadata is available through -`Logger.logAt(...)`; automatic compiler call-site capture remains the next -compiler/runtime polish step. - -Tier 3 backend primitives have landed in the base libraries. A full -configuration-file loader and rolling-file/network destinations are deliberately not -shipped in this branch; the docs explain where those pure-XTC backend modules belong if -the canonical `lib_logging` API is accepted. - -## Backend boundary +--- -This branch deliberately keeps the backend boundary pure XTC: `Logger` talks to -`LogSink`, and the implemented `LogSink` classes cover console, JSON, fanout, -hierarchical levels, async buffering, and test capture. +Next: [`usage/injected-logger-example.md`](usage/injected-logger-example.md) → diff --git a/doc/logging/api-cross-reference.md b/doc/logging/api-cross-reference.md index dd4709abcc..80f19a86bf 100644 --- a/doc/logging/api-cross-reference.md +++ b/doc/logging/api-cross-reference.md @@ -1,5 +1,7 @@ # Logging API cross-reference +> **Audience:** reviewers and porters who want a single page mapping every Ecstasy type to its SLF4J or Go slog counterpart. + This page maps the Ecstasy POC types to the well-known APIs they intentionally mirror. Use it when reading the source: the point is not "we copied Java" or "we copied Go", but "we reused the smallest familiar shape, then adapted acquisition, @@ -115,7 +117,6 @@ makes the compromise explicit, for example `lib_slogging_slf4j_adapter`. - The biggest Ecstasy-specific change in both designs is acquisition: the long-term shape should be container-controlled injection, not global static configuration. - --- -_See also [README.md](README.md) for the full doc index and reading paths._ +Reference doc — dipped into rather than read straight through. Up: [`README.md`](README.md) diff --git a/doc/logging/cloud-integration.md b/doc/logging/cloud-integration.md index a8c0517837..552b97266b 100644 --- a/doc/logging/cloud-integration.md +++ b/doc/logging/cloud-integration.md @@ -233,7 +233,6 @@ shipping into an empty ecosystem and writing the integrations yourself. This document exists so that decision is made with that context, not without it. - --- -_See also [README.md](README.md) for the full doc index and reading paths._ +Previous: [`concepts/markers.md`](concepts/markers.md) | Next: [`design/design.md`](design/design.md) → | Up: [`README.md`](README.md) diff --git a/doc/logging/concepts/markers.md b/doc/logging/concepts/markers.md new file mode 100644 index 0000000000..3f22e65fdb --- /dev/null +++ b/doc/logging/concepts/markers.md @@ -0,0 +1,299 @@ +# Log markers + +> **Audience:** anyone evaluating `lib_logging` who has not used SLF4J/Logback +> markers in production. Read this if you see `Marker` in the API and wonder +> what it earns. +> +> **Length-conscious read:** the [bottom line](#bottom-line) tells you the +> outcome in two paragraphs. The rest is supporting detail. + +## What a marker is + +A **log marker** is a *named tag* attached to a log record that gives sinks a +first-class "category" to filter on, separate from the message text and the +severity level. SLF4J introduced the concept in 2005; the same idea exists in +Log4j 2 (`Marker`) and is conceptually similar to `MDC` keys in Logback config +or "properties" in log4net — but markers travel *with the event* whereas MDC +travels *with the thread*. + +Concrete use cases — all from production deployments using SLF4J/Logback: + +- **`AUDIT`** — route every event tagged with this marker to an *append-only* + audit log that satisfies SOX / PCI-DSS / HIPAA retention rules. The same + logger writes ordinary events to a normal file; the audit log only sees + marker-tagged events. +- **`SECURITY`** / **`SECURITY.BREACH`** — forward security-relevant events to + a SIEM (Splunk, Elastic, Datadog) on a separate channel, optionally with + stricter encryption / redaction filters in front. +- **`SLOW_QUERY`** / **`PERFORMANCE`** — emit perf-suspect events to an APM + pipeline (New Relic, AppDynamics) without the noise of regular debug logs. +- **`BILLING`** / **`CONFIDENTIAL`** — route financial-impact events to a + system with longer retention and tighter access controls. +- **`PUBLIC_API_REQUEST`** — tee API-boundary events to a separate access log + used for traffic analysis and rate-limit tuning. + +Markers also support a *parent/child DAG*: in SLF4J, +`SECURITY.contains(BREACH)` returns true if `BREACH` was added as a child of +`SECURITY`. A filter configured on `SECURITY` therefore catches any descendant +marker without needing to know their names. This is the marker model's only +real edge over a plain `is_security: true` attribute — and almost no +production code uses it. + +## Who actually uses markers + +Inside the JVM ecosystem, marker support is widespread: + +- **Logback** (the dominant SLF4J binding): markers are first-class. + `TurboFilter` and `EvaluatorFilter` can route events by marker. Most JSON + encoders emit the marker name as a structured field. Used in **almost every + Java enterprise app** built between roughly 2010 and 2020. +- **Log4j 2**: `MarkerManager` interns markers; `MarkerFilter` accepts/denies + by marker name. Used heavily in Apache projects (Kafka, Cassandra, Solr) and + in legacy enterprise codebases. +- **SLF4J 2.x** itself: `Logger.atInfo().addMarker(...)` is the canonical + fluent builder usage, and `LoggingEvent.getMarkers(): List` is the + v2 wire format. +- **Apache Camel, Spring Boot, JBoss / WildFly, ActiveMQ, Hazelcast** — all + surface marker-based filtering in their default Logback / Log4j configs. + +Outside the JVM, markers are deliberately absent: + +- **Go's `log/slog`** rejects markers. Categorisation is just + `slog.Bool("audit", true)` or `slog.String("category", "security")`. +- **Python's `logging`** has no markers. Categories live in the logger name + hierarchy (`security.breach`) and in `extra={...}` dict attrs. +- **.NET's `Microsoft.Extensions.Logging`** has no markers. Categories are + logger names; structured fields go in the `state` object. +- **Rust's `tracing`** has no markers. The `target` field on each event + approximates the same idea; otherwise `field::display(...)` attributes. + +The pattern: **markers are a JVM-ecosystem feature**, born of SLF4J, used +widely *inside* the JVM, and deliberately excluded from every well-known +modern logger designed since 2015. + +## What is the actual payoff + +Three things, in decreasing order of how often they justify the API surface: + +1. **Backend routing.** A sink looks at the marker and decides where the event + goes (audit log vs. main log vs. SIEM). This is how 90% of production + marker usage works in practice. It is the *only* real pull for keeping + markers. +2. **Marker DAG queries.** `SECURITY.contains(BREACH)` and `marker.iterator` + walking parent references. Mostly unused outside of niche audit frameworks. +3. **Marker as a typed-tag** — a way to say "this is the audit log line" + without spelling out a `"category"` string. Same as a string-keyed boolean + attr; the "type-ness" is a thin gain. + +Backend routing is real, but it is the same operation as filtering on +`attrs["category"] == "AUDIT"`. The marker concept is *one type to learn* and +gives you a slightly cheaper filter (interned reference identity vs string +equality). Whether that's worth a separate type in the API depends on whether +the audience reflexively reaches for `MarkerFactory.getMarker("AUDIT")` (JVM +veterans: yes; everyone else: no). + +## Marker patterns side-by-side + +The cases below cover what marker users actually do in production. Each shows +the SLF4J idiom (`lib_logging`) and the slog equivalent (`lib_slogging`). + +### Single category tag — "this is an audit event" + +```ecstasy +// lib_logging +logger.atInfo() + .addMarker(MarkerFactory.getMarker("AUDIT")) + .log("user signed in"); +``` + +```ecstasy +// lib_slogging +logger.info("user signed in", [Attr.of("audit", True)]); +// or, if you prefer string-tag style: +logger.info("user signed in", [Attr.of("category", "AUDIT")]); +``` + +Cloud-side rendering (GCP Cloud Logging, JSON): + +```jsonc +// from lib_logging via the Logback Cloud Logging appender: +{ "severity": "INFO", "message": "user signed in", "labels": { "AUDIT": "true" } } + +// from lib_slogging via slog's JSONHandler: +{ "level": "INFO", "msg": "user signed in", "audit": true } +``` + +The SLF4J adapter shoves markers into `labels` (a string-only flat map). The +slog version emits the boolean directly into `jsonPayload`. **Both are +queryable in Cloud Logging.** A query for "all audit events" is +`labels.AUDIT="true"` in the SLF4J case and `jsonPayload.audit=true` in the +slog case — same operational outcome, different cell in the JSON. + +### Multiple markers on one event + +```ecstasy +// lib_logging — fluent builder accumulates +Marker AUDIT = MarkerFactory.getMarker("AUDIT"); +Marker SECURITY = MarkerFactory.getMarker("SECURITY"); + +logger.atInfo() + .addMarker(AUDIT) + .addMarker(SECURITY) + .log("login attempt"); +``` + +```ecstasy +// lib_slogging — just two attrs +logger.info("login attempt", [ + Attr.of("audit", True), + Attr.of("security", True), +]); +``` + +Multi-marker is what motivated W-1 in [`../open-questions.md`](../open-questions.md). +In slog the question doesn't arise — repeated attrs are the only shape. + +### Hierarchical relationships — "BREACH is a kind of SECURITY" + +This is the one case where markers genuinely add something attributes don't. +SLF4J markers can have child references; a filter on `SECURITY` catches every +descendant marker without enumerating them. + +```ecstasy +// lib_logging — DAG via Marker.add +Marker SECURITY = MarkerFactory.getMarker("SECURITY"); +Marker BREACH = MarkerFactory.getMarker("BREACH"); +SECURITY.add(BREACH); + +logger.atError().addMarker(BREACH).log("token leak detected"); + +// any filter configured to match SECURITY *also* matches BREACH: +assert SECURITY.contains(BREACH); +``` + +```ecstasy +// lib_slogging — emit both names; the filter does prefix matching +logger.error("token leak detected", [ + Attr.of("category", "SECURITY.BREACH"), + Attr.of("parentCategory", "SECURITY"), // optional, helps simple filters +]); +``` + +**This is the strongest argument for keeping markers in `lib_logging`** — the +DAG model is genuinely more expressive than the slog equivalent. **It is also +the marker feature production code rarely uses.** Most operational filters are +`category == "SECURITY.*"` glob matches at the cloud-product level, not +transitive containment queries inside the application. + +### Sink/handler-side routing — "send audit events to a separate file" + +```ecstasy +// lib_logging — a sink that filters on event.markers +const AuditFilteringLogSink(LogSink delegate, String requiredMarkerName) + implements LogSink { + @Override + Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) = + marker?.containsName(requiredMarkerName) ?: False; + @Override + void log(LogEvent event) { + for (Marker m : event.markers) { + if (m.containsName(requiredMarkerName)) { + delegate.log(event); + return; + } + } + } +} + +new AuditFilteringLogSink(new FileLogSink("/var/log/audit.log"), "AUDIT"); +``` + +```ecstasy +// lib_slogging — a handler that filters on attrs +const AuditFilteringHandler(Handler delegate, String requiredKey) + implements Handler { + @Override + Boolean enabled(Level level) = delegate.enabled(level); + @Override + void handle(Record record) { + for (Attr a : record.attrs) { + if (a.key == requiredKey && a.value == True) { + delegate.handle(record); + return; + } + } + } + @Override Handler withAttrs(Attr[] attrs) = this; + @Override Handler withGroup(String name) = this; +} + +new AuditFilteringHandler(new FileHandler("/var/log/audit.log"), "audit"); +``` + +Both implementations are about the same length. The slog one is slightly +simpler (no nested `containsName` walk on the marker DAG). The SLF4J one is +more powerful (matches transitively). + +### Programmatic categorisation from a dynamic source + +When the category isn't known at compile time (e.g. it's read from a request +header): + +```ecstasy +// lib_logging +String catName = request.header("x-log-category") ?: "DEFAULT"; +Marker cat = MarkerFactory.getMarker(catName); // interned by name +logger.atInfo().addMarker(cat).log("event"); +``` + +```ecstasy +// lib_slogging +String catName = request.header("x-log-category") ?: "DEFAULT"; +logger.info("event", [Attr.of("category", catName)]); +``` + +The marker version interns by name, so repeated requests for `"AUDIT"` get +the same `Marker` instance — slightly cheaper to compare against in a filter. +The attribute version allocates a new `String` value each time. For most +production workloads this difference is too small to measure. + +## Summary table + +| Concern | `lib_logging` (markers) | `lib_slogging` (attrs) | +|---|---|---| +| Single category | `addMarker(M)` | `Attr.of("k", v)` | +| Multiple categories | `addMarker(M1); addMarker(M2)` | `[Attr.of(...), Attr.of(...)]` | +| Hierarchical (DAG) categories | `parent.add(child); marker.contains(other)` | dotted naming + prefix filter | +| Sink-side routing | `marker?.containsName(X)` | `attr.key == X && attr.value == True` | +| Dynamic/runtime categorisation | `MarkerFactory.getMarker(name)` (interned) | `Attr.of("category", name)` | +| Identity comparison cost | reference equality on interned Marker | string equality on `key`/`value` | +| Cloud-side rendering | usually emitted as `labels.{name}` | usually emitted as a top-level JSON field | +| Familiar to Java/JVM teams | yes | no | +| Familiar to Go / cloud-native teams | no | yes | +| Forces a separate API concept | yes | no | + +## Bottom line + +For an **SLF4J-shaped library**, markers are part of the contract. Production +Logback configs filter on markers; existing code uses `MarkerFactory`; the +fluent builder's `.addMarker(...)` is the standard idiom. Dropping it forces +JVM users to rewrite their mental model, which is exactly what an SLF4J-shaped +library is trying to avoid. **`lib_logging` keeps multi-marker support.** + +For a **slog-shaped library**, importing markers would be the wrong move. +slog's single-concept attribute model is the half of its design that *is* +worth copying — markers are a separate type for what is otherwise just a +string-keyed flag, and the Go ecosystem deliberately voted against them. +**`lib_slogging` ships without markers.** Categorisation is +`Attr.of("category", "AUDIT")` and DAG queries are simulated by either dotted +naming + prefix-match filters in the handler, or by emitting both `category` +and `parentCategory` attrs. + +If the comparison surfaces "we want markers in the slog-shaped library," that +would be a strong signal the slog design is *not* the right fit and the SLF4J +shape should win — because at that point the simplification motivating slog +has been undone. + +--- + +Previous: [`../lib-logging-vs-lib-slogging.md`](../lib-logging-vs-lib-slogging.md) | Up: [`../README.md`](../README.md) diff --git a/doc/logging/design/design.md b/doc/logging/design/design.md index 194e12bc57..d711428f25 100644 --- a/doc/logging/design/design.md +++ b/doc/logging/design/design.md @@ -1,5 +1,7 @@ # lib_logging — Design +> **Audience:** anyone reading `lib_logging` source. Architecture and the API↔impl boundary. + ## High-level architecture ``` @@ -275,7 +277,6 @@ fiber-locals survive injection. The real `MessageFormatter` is implemented (12 t `MessageFormatterTest`). Tests live in `lib_logging/src/test/x/LoggingTest/` (66 passing as of this commit). - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`../cloud-integration.md`](../cloud-integration.md) | Next: [`why-slf4j-and-injection.md`](why-slf4j-and-injection.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/design/why-slf4j-and-injection.md b/doc/logging/design/why-slf4j-and-injection.md index 58cd94c74d..2f4dc902c5 100644 --- a/doc/logging/design/why-slf4j-and-injection.md +++ b/doc/logging/design/why-slf4j-and-injection.md @@ -1,5 +1,7 @@ # Why model `lib_logging` on SLF4J and on Ecstasy injection +> **Audience:** reviewers asking "why SLF4J? why injection?" — the rationale doc. + This is the rationale doc. It exists to make the case that the two big shape-defining choices in `lib_logging` — _model the API on SLF4J_, _acquire loggers via `@Inject`_ — are not arbitrary preferences but the only choices that combine into a logging library @@ -326,7 +328,6 @@ The result is still recognizable to SLF4J users, but acquisition and backend selection follow Ecstasy's container model instead of Java's process-global binding model. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`design.md`](design.md) | Next: [`xdk-alignment.md`](xdk-alignment.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/design/xdk-alignment.md b/doc/logging/design/xdk-alignment.md index 7e08f178b7..50f298e59d 100644 --- a/doc/logging/design/xdk-alignment.md +++ b/doc/logging/design/xdk-alignment.md @@ -1,5 +1,7 @@ # XDK lib alignment — making `lib_logging` a first-class citizen +> **Audience:** XDK maintainers checking `lib_logging` against the conventions of other shipped libs. + This document audits `lib_logging` against the conventions used by the other shipped XDK libs (`lib_ecstasy`, `lib_cli`, `lib_crypto`, `lib_json`, `lib_jsondb`, `lib_net`, `lib_oodb`, `lib_sec`, `lib_web`, `lib_webauth`, `lib_xenia`, `lib_xunit`) and against @@ -210,7 +212,6 @@ The only thing missing for full first-class status is the runtime-side resource registration (`@Inject Logger` actually resolving), which is the next chunk of work described in `../open-questions.md` items 1, 2, and 9. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`why-slf4j-and-injection.md`](why-slf4j-and-injection.md) | Next: [`../roadmap.md`](../roadmap.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/future/logback-integration.md b/doc/logging/future/logback-integration.md index 1070099cd8..866f71b521 100644 --- a/doc/logging/future/logback-integration.md +++ b/doc/logging/future/logback-integration.md @@ -1,5 +1,7 @@ # Logback-style integration +> **Audience:** future-work readers — what a configuration-driven backend module would look like. + `lib_logging` now ships the base Logback-style primitives directly: `CompositeLogSink`, `HierarchicalLogSink`, `AsyncLogSink`, `JsonLogSink`, and `JsonLogSinkOptions`. @@ -293,7 +295,6 @@ These are explicitly later: These exist in Java Logback. They are infrequently used and can be added incrementally without API changes. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Future-work sketch. Up: [`../roadmap.md`](../roadmap.md), [`../README.md`](../README.md) diff --git a/doc/logging/lib-logging-vs-lib-slogging.md b/doc/logging/lib-logging-vs-lib-slogging.md index d09224312c..13f7ae31eb 100644 --- a/doc/logging/lib-logging-vs-lib-slogging.md +++ b/doc/logging/lib-logging-vs-lib-slogging.md @@ -1,5 +1,7 @@ # `lib_logging` vs `lib_slogging` — design comparison +> **Audience:** reviewers picking the canonical XDK logging API shape. + Two parallel XDK logging libraries model the same end-to-end capability but follow different prior-art traditions. @@ -328,301 +330,25 @@ want reviewer thoughts on which side of this tradeoff Ecstasy programs should be ### 4.3 Structured data — three concepts vs one -#### Aside: what is a log marker? - -A **log marker** is a *named tag* attached to a log record that gives sinks a -first-class "category" to filter on, separate from the message text and the -severity level. SLF4J introduced the concept in 2005; the same idea exists in -Log4j 2 (`Marker`) and is conceptually similar to `MDC` keys in Logback config or -"properties" in log4net — but markers travel *with the event* whereas MDC travels -*with the thread*. - -Concrete use cases — all from production deployments using SLF4J/Logback: - -- **`AUDIT`**: route every event tagged with this marker to an *append-only* audit - log that satisfies SOX / PCI-DSS / HIPAA retention rules. The same logger writes - ordinary events to a normal file; the audit log only sees marker-tagged events. -- **`SECURITY`** / **`SECURITY.BREACH`**: forward security-relevant events to a - SIEM (Splunk, Elastic, Datadog) on a separate channel, optionally with stricter - encryption / redaction filters in front. -- **`SLOW_QUERY`** / **`PERFORMANCE`**: emit perf-suspect events to an APM - pipeline (New Relic, AppDynamics) without the noise of regular debug logs. -- **`BILLING`** / **`CONFIDENTIAL`**: route financial-impact events to a system - with longer retention and tighter access controls. -- **`PUBLIC_API_REQUEST`**: tee API-boundary events to a separate access log used - for traffic analysis and rate-limit tuning. - -Markers also support a *parent/child DAG*: in SLF4J, `SECURITY.contains(BREACH)` -returns true if `BREACH` was added as a child of `SECURITY`. A filter configured -on `SECURITY` therefore catches any descendant marker without needing to know -their names. This is the marker model's only real edge over a plain -"`is_security: true`" attribute — and almost no production code uses it. - -#### Who actually uses markers? - -- **Logback** (the dominant SLF4J binding): markers are first-class. `TurboFilter` - and `EvaluatorFilter` can route events by marker. Most JSON encoders emit the - marker name as a structured field. Used in **almost every Java enterprise app** - built between roughly 2010 and 2020. -- **Log4j 2**: `MarkerManager` interns markers; `MarkerFilter` accepts/denies by - marker name. Used heavily in Apache projects (Kafka, Cassandra, Solr) and in - legacy enterprise codebases. -- **SLF4J 2.x** itself: `Logger.atInfo().addMarker(...)` is the canonical fluent - builder usage, and `LoggingEvent.getMarkers(): List` (the multi-marker - shape we just adopted) is the v2 wire format. -- **Apache Camel, Spring Boot, JBoss / WildFly, ActiveMQ, Hazelcast**: all surface - marker-based filtering in their default Logback / Log4j configurations. - -Outside the JVM: - -- **Go's `log/slog`** *deliberately rejects* markers. Categorisation is just - `slog.Bool("audit", true)` or `slog.String("category", "security")`. -- **Python's `logging`**: no markers. Categories live in the logger name - hierarchy (`security.breach`) and in `extra={...}` dict attrs. -- **.NET's `Microsoft.Extensions.Logging`**: no markers. Categories are logger - names; structured fields go in the `state` object. -- **Rust's `tracing`**: no markers. `target` field on each event approximates the - same idea; otherwise use `field::display(...)` attributes. - -The pattern: **markers are a JVM-ecosystem feature**, born of SLF4J, used widely -*inside* the JVM, and deliberately excluded from every well-known modern logger -designed since 2015. - -#### What is the actual payoff? - -Three things, in decreasing order of how often they justify the API surface: - -1. **Backend routing.** A sink looks at the marker and decides where the event - goes (audit log vs. main log vs. SIEM). This is how 90% of production marker - usage works in practice. It is the *only* real pull for keeping markers. -2. **Marker DAG queries.** `SECURITY.contains(BREACH)` and `marker.iterator` - walking parent references. Mostly unused outside of niche audit frameworks. -3. **Marker as a typed-tag**: a way to say "this is the audit log line" without - spelling out a `"category"` string. Same as a string-keyed boolean attr; the - "type-ness" is a thin gain. - -Backend routing is real, but it is the same operation as filtering on -`attrs["category"] == "AUDIT"`. The marker concept is *one type to learn* and -gives you a slightly cheaper filter (interned reference identity vs string -equality). Whether that's worth a separate type in the API depends on whether -the audience reflexively reaches for `MarkerFactory.getMarker("AUDIT")` (JVM -veterans: yes; everyone else: no). - -#### My opinion: keep markers in `lib_logging`, drop them in `lib_slogging` - -For an **SLF4J-shaped library**, markers are part of the contract. Production -Logback configs filter on markers; existing code uses `MarkerFactory`; the -fluent builder's `.addMarker(...)` is the standard idiom. Dropping it forces -JVM users to rewrite their mental model, which is exactly what the SLF4J-shaped -library is trying to avoid. **`lib_logging` keeps multi-marker support.** - -For a **slog-shaped library**, importing markers would be the wrong move. slog's -single-concept attribute model is the half of its design that *is* worth -copying — markers are a separate type for what is otherwise just a string-keyed -flag, and the Go ecosystem deliberately voted against them. **`lib_slogging` -ships without markers.** Categorisation is `Attr.of("category", "AUDIT")` and -DAG queries are simulated by either dotted naming + prefix-match filters in the -handler, or by emitting both `category` and `parentCategory` attrs. - -If the comparison surfaces "we want markers in the slog-shaped library," that -would be a strong signal the slog design is *not* the right fit and the SLF4J -shape should win — because at that point the simplification motivating slog has -been undone. - -#### Side-by-side: how each library expresses common marker patterns - -The four cases below cover what marker users actually do in production. Each -shows the SLF4J idiom (`lib_logging`) and the slog equivalent (`lib_slogging`). - -##### Pattern 1 — single category tag ("this is an audit event") - -```ecstasy -// lib_logging -logger.atInfo() - .addMarker(MarkerFactory.getMarker("AUDIT")) - .log("user signed in"); -``` - -```ecstasy -// lib_slogging -logger.info("user signed in", [Attr.of("audit", True)]); -// or, if you prefer string-tag style: -logger.info("user signed in", [Attr.of("category", "AUDIT")]); -``` - -Cloud-side rendering (GCP Cloud Logging, JSON): - -```jsonc -// from lib_logging via the Logback Cloud Logging appender: -{ "severity": "INFO", "message": "user signed in", "labels": { "AUDIT": "true" } } - -// from lib_slogging via slog's JSONHandler: -{ "level": "INFO", "msg": "user signed in", "audit": true } -``` - -The SLF4J adapter shoves markers into `labels` (a string-only flat map). The -slog version emits the boolean directly into `jsonPayload`. **Both are -queryable in Cloud Logging.** A query for "all audit events" is -`labels.AUDIT="true"` in the SLF4J case and `jsonPayload.audit=true` in the -slog case — same operational outcome, different cell in the JSON. - -##### Pattern 2 — multiple markers on one event - -```ecstasy -// lib_logging — fluent builder accumulates -Marker AUDIT = MarkerFactory.getMarker("AUDIT"); -Marker SECURITY = MarkerFactory.getMarker("SECURITY"); - -logger.atInfo() - .addMarker(AUDIT) - .addMarker(SECURITY) - .log("login attempt"); -``` - -```ecstasy -// lib_slogging — just two attrs -logger.info("login attempt", [ - Attr.of("audit", True), - Attr.of("security", True), -]); -``` +#### Markers — what they are and which library keeps them -Multi-marker is what motivated W-1 in `open-questions.md`. In slog the -question doesn't arise — repeated attrs are the only shape. +A **log marker** is a named, event-scoped tag SLF4J/Logback users reach for +when categorising events for backend routing (audit/security/billing channels). +The full explainer — what they are, who uses them, the patterns they cover, and +the cloud-side rendering — lives in [`concepts/markers.md`](concepts/markers.md). +The summary, for the comparison axis here: -##### Pattern 3 — hierarchical relationships ("BREACH is a kind of SECURITY") +- **`lib_logging` keeps markers.** SLF4J users expect them; Logback configs + filter on them; the marker-aware Cloud Logging adapter exists. +- **`lib_slogging` drops markers.** Go `slog` deliberately omits them; every + use case maps to attributes, with one corner the attribute model loses (the + DAG containment query). Importing markers into a slog-shaped library would + undo the simplification that motivates copying slog in the first place. -This is the one case where markers genuinely add something attributes don't. -SLF4J markers can have child references; a filter on `SECURITY` catches every -descendant marker without enumerating them. - -```ecstasy -// lib_logging — DAG via Marker.add -Marker SECURITY = MarkerFactory.getMarker("SECURITY"); -Marker BREACH = MarkerFactory.getMarker("BREACH"); -SECURITY.add(BREACH); - -logger.atError().addMarker(BREACH).log("token leak detected"); - -// any filter configured to match SECURITY *also* matches BREACH: -assert SECURITY.contains(BREACH); -``` - -```ecstasy -// lib_slogging — emit both names; the filter does prefix matching -logger.error("token leak detected", [ - Attr.of("category", "SECURITY.BREACH"), - Attr.of("parentCategory", "SECURITY"), // optional, helps simple filters -]); -``` +If the comparison surfaces "we want markers in the slog-shaped library", that +is a strong signal the slog design is not the right fit and the SLF4J shape +should win. -**This is the strongest argument for keeping markers in `lib_logging`** — -the DAG model is genuinely more expressive than the slog equivalent. **It is -also the marker feature production code rarely uses.** Most operational -filters are `category == "SECURITY.*"` glob matches at the cloud-product -level, not transitive containment queries inside the application. - -##### Pattern 4 — sink/handler-side routing ("send audit events to a separate file") - -```ecstasy -// lib_logging — a sink that filters on event.markers -const AuditFilteringLogSink(LogSink delegate, String requiredMarkerName) - implements LogSink { - @Override - Boolean isEnabled(String loggerName, Level level, Marker? marker = Null) = - marker?.containsName(requiredMarkerName) ?: False; - @Override - void log(LogEvent event) { - for (Marker m : event.markers) { - if (m.containsName(requiredMarkerName)) { - delegate.log(event); - return; - } - } - } -} - -new AuditFilteringLogSink(new FileLogSink("/var/log/audit.log"), "AUDIT"); -``` - -```ecstasy -// lib_slogging — a handler that filters on attrs -const AuditFilteringHandler(Handler delegate, String requiredKey) - implements Handler { - @Override - Boolean enabled(Level level) = delegate.enabled(level); - @Override - void handle(Record record) { - for (Attr a : record.attrs) { - if (a.key == requiredKey && a.value == True) { - delegate.handle(record); - return; - } - } - } - @Override Handler withAttrs(Attr[] attrs) = this; - @Override Handler withGroup(String name) = this; -} - -new AuditFilteringHandler(new FileHandler("/var/log/audit.log"), "audit"); -``` - -Both implementations are about the same length. The slog one is slightly -simpler (no nested `containsName` walk on the marker DAG). The SLF4J one is -more powerful (matches transitively). - -##### Pattern 5 — programmatic categorisation from a dynamic source - -When the category isn't known at compile time (e.g. it's read from a request -header): - -```ecstasy -// lib_logging -String catName = request.header("x-log-category") ?: "DEFAULT"; -Marker cat = MarkerFactory.getMarker(catName); // interned by name -logger.atInfo().addMarker(cat).log("event"); -``` - -```ecstasy -// lib_slogging -String catName = request.header("x-log-category") ?: "DEFAULT"; -logger.info("event", [Attr.of("category", catName)]); -``` - -The marker version interns by name, so repeated requests for `"AUDIT"` get -the same `Marker` instance — slightly cheaper to compare against in a filter. -The attribute version allocates a new `String` value each time. For most -production workloads this difference is too small to measure. - -#### A summary table - -| Concern | `lib_logging` (markers) | `lib_slogging` (attrs) | -|---|---|---| -| Single category | `addMarker(M)` | `Attr.of("k", v)` | -| Multiple categories | `addMarker(M1); addMarker(M2)` | `[Attr.of(...), Attr.of(...)]` | -| Hierarchical (DAG) categories | `parent.add(child); marker.contains(other)` | dotted naming + prefix filter | -| Sink-side routing | `marker?.containsName(X)` | `attr.key == X && attr.value == True` | -| Dynamic/runtime categorisation | `MarkerFactory.getMarker(name)` (interned) | `Attr.of("category", name)` | -| Identity comparison cost | reference equality on interned Marker | string equality on `key`/`value` | -| Cloud-side rendering | usually emitted as `labels.{name}` | usually emitted as a top-level JSON field | -| Familiar to Java/JVM teams | yes | no | -| Familiar to Go / cloud-native teams | no | yes | -| Forces a separate API concept | yes | no | - -#### Bottom line on markers - -`lib_slogging` does not have markers because *slog deliberately doesn't*. Every -marker use case maps onto attributes, with one corner the attribute model -loses (the DAG containment query). Whether that loss is worth keeping a -separate type for depends on the audience. - -The two libraries treat this differently on purpose: `lib_logging` carries -markers because SLF4J users expect them and the marker-aware Cloud Logging -adapter exists; `lib_slogging` omits markers because slog users expect them -to be omitted and the attribute-only API is the half of slog that's worth -copying. - ---- #### The three concepts in `lib_logging` @@ -913,7 +639,6 @@ The branch is now opinionated: Q-D6 is answered as `lib_logging`. Review can sti challenge that decision, but the implementation and docs no longer leave the reader with two equally recommended facades. - --- -_See also [README.md](README.md) for the full doc index and reading paths._ +Previous: [`usage/custom-handlers.md`](usage/custom-handlers.md) | Next: [`concepts/markers.md`](concepts/markers.md) → | Up: [`README.md`](README.md) diff --git a/doc/logging/open-questions.md b/doc/logging/open-questions.md index 4d423b9d7b..c11b6561b5 100644 --- a/doc/logging/open-questions.md +++ b/doc/logging/open-questions.md @@ -1,5 +1,7 @@ # Logging decision tracker and open questions +> **Audience:** XTC language/runtime designers and reviewers tracking what is decided vs still open. + This is the running list of design decisions and remaining implementation questions, kept deliberately short so it stays readable as the project moves. @@ -264,7 +266,6 @@ review have been closed as follows: The remaining genuinely open items are compiler/tooling work, destination-specific production backends, and any optional Java bridge. - --- -_See also [README.md](README.md) for the full doc index and reading paths._ +Previous: [`roadmap.md`](roadmap.md) | Next: [`README.md`](README.md) → | Up: [`README.md`](README.md) diff --git a/doc/logging/roadmap.md b/doc/logging/roadmap.md new file mode 100644 index 0000000000..efe8288cf1 --- /dev/null +++ b/doc/logging/roadmap.md @@ -0,0 +1,73 @@ +# Logging roadmap + +> **Audience:** reviewers deciding whether to merge this branch and what should +> follow. Read this once before [`open-questions.md`](open-questions.md). + +This page is the single answer to *"what blocks merge, what lands in v1, what +is explicit future?"*. The detailed history of each item — alternatives +considered, rejected variants, who pushed back — lives in +[`open-questions.md`](open-questions.md). Treat that as the supporting evidence, +this as the index. + +## Status legend + +| Mark | Meaning | +|---|---| +| ✓ | Done in this branch — code, tests, docs all present | +| ~ | Partial — runtime works, compiler/tooling polish missing | +| → | Planned for v1 (post-merge) | +| + | Future, no commitment | +| ? | Open — needs a decision before merge | + +## Merge-blocking + +Items the reviewers must resolve before this branch can graduate from POC. + +| Item | State | Where | +|---|---|---| +| Pick `lib_logging` (SLF4J shape) vs `lib_slogging` (slog shape) as the canonical XDK API. | ? | [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md), open question Q-D6 | +| Confirm the `const`/`service` rule for SPI implementations is idiomatic XDK. | ? | open question Q-D1 | +| Confirm `@Inject` inside a `const` is fully supported (semantics, fiber affinity, freeze). | ? | open question Q-D2 | +| Confirm per-fiber `MDC` via `SharedContext` matches intended use. | ? | open question Q-D4 | +| Decide whether cross-module default-arg synthesis on `const` constructors is a bug or expected. | ? | open question Q-D7 | + +## v1 (post-merge, before users depend on it) + +Items the chosen library must land before downstream code starts to migrate. + +| Item | State | Notes | +|---|---|---| +| Compiler-synthesized default logger names. | → | Runtime fallback derives from caller namespace today; the compiler should emit the exact module/class name as the resource name. R10 in the README. | +| Compiler-synthesized source location. | → | `Logger.logAt(...)` is the lowering target. `logger.info(...)` calls do not yet get sourceFile/sourceLine populated automatically. R10 in the README. | +| `AsyncLogSink.drain` — `try/finally` to release `draining` on delegate exception. | → | Defensive: `LogSink.log` is contractually non-throwing, but the async wrapper should recover from a misbehaving delegate. Code review item. | +| `AsyncLogSink` queue dequeue performance — replace `Array.delete(0)` (O(n)) with a circular buffer. | → | Currently O(n²) per drain; only matters at scale. | +| Strip the asymmetric `xdk-logging` dep from `javatools_bridge/build.gradle.kts` (or add `xdk-slogging` for parity). | → | Bridge `_native.x` imports neither. Code review item. | +| Migrate `~/src/platform` ad-hoc `console.print($"… Info :")` lines to the chosen `Logger`. | → | Survey done in [`usage/platform-and-examples-adaptation.md`](usage/platform-and-examples-adaptation.md). | +| Confirm `addResourceSupplier` lookup performance is fine (O(n) loop over ~20 entries vs the previous HashMap by name). | → | Likely a non-issue, but worth checking once the resource table grows. | + +## Future, no commitment + +Items deliberately out of scope for the base library. They are pure-XTC modules +built on top of the SPI; nothing about them belongs in `lib_logging` itself. + +| Item | State | Where | +|---|---|---| +| Configuration-file driven backend (`lib_logging_logback`). | + | Sketched in [`future/logback-integration.md`](future/logback-integration.md) | +| Rolling/network/file destinations. | + | Live behind `LogSink`; out of scope for the base lib. | +| Provider-specific cloud sinks (GCP Cloud Logging, CloudWatch, App Insights). | + | Each is a thin adapter over `LogEvent`. See [`cloud-integration.md`](cloud-integration.md). | +| Distributed tracing context propagation (trace/span IDs as a first-class concern). | + | MDC carries strings; tracing is a separate library. | +| XTC linter for log-statement footguns (`info("count: " + n)`, missing exception arg). | + | Linter feature, not a library feature. Open question 12. | +| Adapter modules (`lib_logging_slf4j_adapter`, `lib_logging_slogging_adapter`) bridging the losing API onto the canonical one. | + | Only if the losing POC has users worth migrating. | +| `service MarkerRegistry` for fiber-safe global marker interning + DAG mutation. | + | The current `class MarkerFactory` covers the per-component pattern; a service-shaped registry would let multiple fibers share one interned marker space. See [`MarkerFactory.x`](../../lib_logging/src/main/x/logging/MarkerFactory.x) "Why a class, not a service" for the design constraint that keeps these as separate types. | + +## Reading order + +Once you have read this page: + +- For the API-choice debate: [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md). +- For the open language/runtime questions: [`open-questions.md`](open-questions.md). +- For the cloud-side context behind the merge decision: [`cloud-integration.md`](cloud-integration.md). + +--- + +Previous: [`README.md`](README.md) | Next: [`lib-logging-vs-lib-slogging.md`](lib-logging-vs-lib-slogging.md) → diff --git a/doc/logging/usage/configuration.md b/doc/logging/usage/configuration.md index d4c8ce6ece..cf752ddf77 100644 --- a/doc/logging/usage/configuration.md +++ b/doc/logging/usage/configuration.md @@ -1,5 +1,7 @@ # Logging configuration and dynamic backends +> **Audience:** ops/host engineers who own backend wiring. Library authors should not need to read this. + Logging configuration belongs below the facade. Application/library code should keep using `@Inject Logger logger;`; the host/container decides which sinks or handlers are active, where output goes, which fields are redacted, and which logger categories are @@ -948,8 +950,6 @@ logger categories unless the handler explicitly implements attr-based routing. The user-facing rule stays the same in both designs: caller code depends on `Logger`; configuration code owns sinks/handlers. - --- -_See also [structured-logging.md](structured-logging.md), [custom-sinks.md](custom-sinks.md), -and [custom-handlers.md](custom-handlers.md)._ +Previous: [`lazy-logging.md`](lazy-logging.md) | Next: [`custom-sinks.md`](custom-sinks.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/custom-handlers.md b/doc/logging/usage/custom-handlers.md index c3eaee9a4e..f8369e8cf4 100644 --- a/doc/logging/usage/custom-handlers.md +++ b/doc/logging/usage/custom-handlers.md @@ -1,5 +1,7 @@ # Writing a custom `Handler` +> **Audience:** engineers writing a new `Handler` for `lib_slogging`. + `Handler` is the backend extension point for `lib_slogging`, the same way `LogSink` is the backend extension point for `lib_logging`. @@ -184,7 +186,6 @@ structurally. A production cloud/file/network handler built on the same contract add destination configuration (`Console`, file, stream, socket, HTTP exporter), retry/drop policy, batching, and backend-specific timestamp or severity formatting. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`custom-sinks.md`](custom-sinks.md) | Next: [`../lib-logging-vs-lib-slogging.md`](../lib-logging-vs-lib-slogging.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/custom-sinks.md b/doc/logging/usage/custom-sinks.md index 4c47a956f5..f118a2a688 100644 --- a/doc/logging/usage/custom-sinks.md +++ b/doc/logging/usage/custom-sinks.md @@ -1,5 +1,7 @@ # Writing a custom `LogSink` +> **Audience:** engineers writing a new `LogSink` for `lib_logging` (file, network, cloud, test capture). + `LogSink` is the API/impl boundary for `lib_logging`. Anything below it is replaceable without touching caller code. This document walks through writing a custom sink end to end. @@ -263,7 +265,6 @@ The sink can also rely on three upstream guarantees: disabled events are filtere before formatting, MDC is captured in the event, and SLF4J-style trailing exceptions have already been promoted to `event.exception`. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`configuration.md`](configuration.md) | Next: [`custom-handlers.md`](custom-handlers.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/ecstasy-vs-java-examples.md b/doc/logging/usage/ecstasy-vs-java-examples.md index f0fd45ce6e..0acadf7b46 100644 --- a/doc/logging/usage/ecstasy-vs-java-examples.md +++ b/doc/logging/usage/ecstasy-vs-java-examples.md @@ -1,5 +1,7 @@ # Ecstasy `lib_logging` vs Java SLF4J — side by side +> **Audience:** SLF4J users porting code to Ecstasy. Per-feature side-by-side. + For every API in `lib_logging` this document shows a working SLF4J 2.x example in Java first, then the equivalent in Ecstasy. The point is to make adoption frictionless: anyone who has used SLF4J should be able to skim once and be productive. @@ -376,7 +378,6 @@ logger.info("escaped \\{}", ["x"]); The semantics are the same — `MessageFormatter` is a port of SLF4J's reference behaviour. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Related: [`slf4j-parity.md`](slf4j-parity.md), [`injected-logger-example.md`](injected-logger-example.md). Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md index 326b461948..d942dd3c59 100644 --- a/doc/logging/usage/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -1,5 +1,7 @@ # `@Inject Logger` end-to-end — a working example +> **Audience:** new users. Start here if you've never touched `lib_logging` before. + This document shows the canonical way an Ecstasy application uses `lib_logging`. The goal is to be the file someone reads when they ask "okay, but what does it actually look like in real code?" @@ -254,4 +256,4 @@ That covers ~95% of real-world logging use. --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`../README.md`](../README.md) | Next: [`structured-logging.md`](structured-logging.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/lazy-logging.md b/doc/logging/usage/lazy-logging.md index a4e558d4bf..73f00af38c 100644 --- a/doc/logging/usage/lazy-logging.md +++ b/doc/logging/usage/lazy-logging.md @@ -1,5 +1,7 @@ # Lazy logging +> **Audience:** anyone whose log calls have non-trivial argument cost. Mandatory reading if you use `debug` heavily. + Lazy logging is a core requirement for the XDK logging API, not a future nice-to-have. Disabled log calls must not build expensive messages, serialize payloads, or compute structured values just to be dropped by the level check. @@ -170,4 +172,4 @@ surface. The API does not depend on that work. --- -_See also [../README.md](../README.md) for the requirements list and reading paths._ +Previous: [`structured-logging.md`](structured-logging.md) | Next: [`configuration.md`](configuration.md) → | Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/platform-and-examples-adaptation.md b/doc/logging/usage/platform-and-examples-adaptation.md index 98632c3370..9eb058a7d4 100644 --- a/doc/logging/usage/platform-and-examples-adaptation.md +++ b/doc/logging/usage/platform-and-examples-adaptation.md @@ -1,5 +1,7 @@ # How `lib_logging` would change `~/src/platform` and `~/src/examples` +> **Audience:** anyone deciding what migrating real Ecstasy code (the `~/src/platform` repo) costs. + This is a forward-looking doc surveying two real Ecstasy code bases that already exist side by side with the XDK in this dev environment, and showing how each would adopt `lib_logging` if it shipped as part of the XDK today. The point is to validate the API @@ -275,7 +277,6 @@ in one chunk and validates the API end-to-end on real code. The examples repo ca follow opportunistically, file by file — there is no urgency, and the gains there are educational rather than operational. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Migration survey. Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/slf4j-parity.md b/doc/logging/usage/slf4j-parity.md index 9998c20816..077ccb6965 100644 --- a/doc/logging/usage/slf4j-parity.md +++ b/doc/logging/usage/slf4j-parity.md @@ -1,5 +1,7 @@ # SLF4J 2.x → lib_logging mapping +> **Audience:** Java/Kotlin/Scala engineers familiar with SLF4J 2.x. Reference doc — scan once, return on demand. + This document inventories every SLF4J 2.x type and major method that user code is likely to touch, and shows the equivalent in `lib_logging`. The intent is that an SLF4J user can scan this once and know everything they need. @@ -124,7 +126,6 @@ SLF4J's `{}` placeholder semantics are reproduced verbatim by `MessageFormatter. - `Off` level, useful for sink configuration. SLF4J expresses this through level checks in `Logger.isEnabledFor(...)`. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Reference doc. Related: [`ecstasy-vs-java-examples.md`](ecstasy-vs-java-examples.md). Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/slog-parity.md b/doc/logging/usage/slog-parity.md index 8fe537c3cc..9d66714a08 100644 --- a/doc/logging/usage/slog-parity.md +++ b/doc/logging/usage/slog-parity.md @@ -1,5 +1,7 @@ # Go `log/slog` → `lib_slogging` mapping +> **Audience:** Go engineers familiar with `log/slog`. Reference doc — scan once, return on demand. + This document is the slog-shaped companion to [`slf4j-parity.md`](slf4j-parity.md). It maps the Go `log/slog` API to the Ecstasy POC and calls out the differences that are intentional. @@ -175,7 +177,6 @@ later, it should live in an adapter module, not in the core slog API. | Automatic source capture | `logAt(...)` is explicit today and is the method future compiler/runtime sugar can target. | | Handler prefix caching | `BoundHandler` provides correct derivation semantics. High-performance handlers can override `withAttrs` / `withGroup` to cache serialized prefixes. | - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Reference doc for Go slog migrators. Related: [`../api-cross-reference.md`](../api-cross-reference.md). Up: [`../README.md`](../README.md) diff --git a/doc/logging/usage/structured-logging.md b/doc/logging/usage/structured-logging.md index e865e0620a..21099eded0 100644 --- a/doc/logging/usage/structured-logging.md +++ b/doc/logging/usage/structured-logging.md @@ -1,5 +1,7 @@ # Structured logging in the logging POC +> **Audience:** anyone shipping Ecstasy code that needs to emit JSON or talk to a structured log aggregator. + This document explains structured logging as a requirement, then shows how the same event looks in Java SLF4J/Logback, Go `log/slog`, Ecstasy `lib_logging`, and Ecstasy `lib_slogging`. @@ -418,7 +420,6 @@ Everything beyond that — typed value formatting, schema validation, OpenTeleme integration, log-correlation IDs propagated across services — is sink-side. The API is already shaped to support it without changes. - --- -_See also [../README.md](../README.md) for the full doc index and reading paths._ +Previous: [`injected-logger-example.md`](injected-logger-example.md) | Next: [`lazy-logging.md`](lazy-logging.md) → | Up: [`../README.md`](../README.md) diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index 7658bf45df..d8b902806e 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -393,9 +393,10 @@ private void registerLoggingResources(ConstantPool pool) { var typeBasic = typeFor(pool, LOGGING_MODULE, "BasicLogger"); var typeMDC = typeFor(pool, LOGGING_MODULE, "MDC"); - m_typeLoggingLogger = typeLogger; - m_typeBasicLogger = typeBasic; - + // Both POC logger types ("logger" + lib_logging.Logger and "logger" + slogging.Logger) + // share the resource name "logger". `addResourceSupplier` permits this because each + // entry's InjectionKey is keyed on (name, type), and `getInjectable` disambiguates + // by type when more than one supplier is registered under one name. addResourceSupplier(new InjectionKey("logger", typeLogger), (frame, hOpts) -> ensureLogger(frame, typeBasic, inferLoggerName(frame, "logger"))); addResourceSupplier(new InjectionKey("mdc", typeMDC), @@ -508,6 +509,13 @@ private ObjectHandle ensureConst(Frame frame, TypeConstant typeConst) { /** * Add a native resource supplier for an injection. * + * Maintains two indexes in lockstep: `f_mapResources` keyed by the full + * `(name, type)` `InjectionKey` (the canonical store), and `f_mapResourceNames` + * keyed by name only (the lookup fast path used by `getInjectable`). Most names + * map to a single key; the list-valued shape exists to support cases like the + * two POC logger libraries, which deliberately register distinct types under the + * same resource name "logger". + * * @param key the injection key * @param supplier the resource supplier */ @@ -515,6 +523,7 @@ private void addResourceSupplier(InjectionKey key, InjectionSupplier supplier) { assert !f_mapResources.containsKey(key); f_mapResources.put(key, supplier); + f_mapResourceNames.computeIfAbsent(key.f_sName, k -> new ArrayList<>()).add(key); } public ObjectHandle ensureOSStorage(Frame frame, ObjectHandle hOpts) { @@ -802,34 +811,27 @@ public ConstantPool getConstantPool() { @Override public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, ObjectHandle hOpts) { - for (var entry : f_mapResources.entrySet()) { - var key = entry.getKey(); - if (!key.f_sName.equals(sName)) { - continue; - } - - // Resolve by (name, type), not just name, while both POC logger APIs use "logger". - if (matchesType(key.f_type, type)) { - return entry.getValue().supply(frame, hOpts); + // Look up by name first via `f_mapResourceNames` — O(1) average — then disambiguate + // by type. The list-per-name shape exists so that two suppliers can share a resource + // name when their types differ; this is what lets `lib_logging.Logger` and + // `slogging.Logger` both register under the name "logger" (see registerLoggingResources + // / registerSLoggingResources). For most names the list has exactly one entry, so the + // inner loop is constant work. + List candidates = f_mapResourceNames.get(sName); + if (candidates != null) { + for (var key : candidates) { + if (matchesType(key.f_type, type)) { + return f_mapResources.get(key).supply(frame, hOpts); + } } } - // TODO: Part of the injected logging POC hack above; the final XDK logging - // injector should resolve default logger names through a more robust mechanism. - if (isCanonicalLoggingLogger(type)) { - return ensureLogger(frame, m_typeBasicLogger, inferLoggerName(frame, sName)); - } - // for "Nullable" types the NativeContainer can only supply a trivial result; // anything better than that must be done naturally by a container that hosts the // calling container return type.isNullable() ? xNullable.NULL : null; } - private boolean isCanonicalLoggingLogger(TypeConstant type) { - return m_typeLoggingLogger != null && matchesType(m_typeLoggingLogger, type); - } - private boolean matchesType(TypeConstant typeResource, TypeConstant typeRequest) { return typeResource.equals(typeRequest) || typeResource.isEquivalent(typeRequest) @@ -1028,13 +1030,6 @@ public String toString() { private ModuleStructure m_moduleTurtle; private ModuleStructure m_moduleNative; - /** - * Cached canonical logging types. Used to resolve module/class-named injections for - * `logging.Logger` after the fixed `"logger"` resource registration. - */ - private TypeConstant m_typeLoggingLogger; - private TypeConstant m_typeBasicLogger; - /** * Map of IdentityConstants by name. */ @@ -1044,4 +1039,13 @@ public String toString() { * Map of resources that are injectable from this container, keyed by their InjectionKey. */ private final Map f_mapResources = new HashMap<>(); + + /** + * Name-only index over `f_mapResources`. Lets `getInjectable` do an O(1) average + * lookup by `sName` and then disambiguate by type, instead of scanning every + * supplier on every injection. The list-valued shape supports more than one + * supplier sharing a name (e.g. the two POC logger libraries both registering + * under "logger" with different types). + */ + private final Map> f_mapResourceNames = new HashMap<>(); } diff --git a/javatools_bridge/build.gradle.kts b/javatools_bridge/build.gradle.kts index b51c5191b9..9045d56df2 100644 --- a/javatools_bridge/build.gradle.kts +++ b/javatools_bridge/build.gradle.kts @@ -17,7 +17,6 @@ dependencies { xtcModule(libs.xdk.convert) xtcModule(libs.xdk.crypto) xtcModule(libs.xdk.json) - xtcModule(libs.xdk.logging) xtcModule(libs.xdk.net) xtcModule(libs.xdk.sec) xtcModule(libs.xdk.web) diff --git a/lib_logging/README.md b/lib_logging/README.md index a6779f90c3..f87b4ec08f 100644 --- a/lib_logging/README.md +++ b/lib_logging/README.md @@ -1,44 +1,19 @@ # lib_logging -Experimental SLF4J-shaped logging library for Ecstasy. - -> **Status:** Working POC. The core API, default sinks, MDC, markers, structured -> key/value events, lazy message/value suppliers, logger interning, runtime -> `@Inject Logger logger;` wiring, and unit tests are in this branch. The base backend -> layer now includes async, -> composite, hierarchical-level, and JSON/redaction sinks. Full configuration-file -> loading remains future work; see [`doc/logging/`](../doc/logging). - -> **POC naming note:** `lib_slogging` exists beside this module only for comparison. -> The XDK should settle on one injectable logging design and ship it as `lib_logging`. - -## What this is - -A logging library for Ecstasy with the SLF4J 2.x shape: named loggers, levels, -parameterized messages with `{}`, exception attachment, markers, MDC, and the SLF4J -2.x fluent event builder. Acquired by injection: +SLF4J 2.x-shaped injectable logging library for Ecstasy. Acquired by injection: ```ecstasy @Inject Logger logger; logger.info("processed {} records in {}ms", [count, elapsed]); ``` -The default sink writes to the platform `Console`. The `LogSink` SPI is the API↔impl -boundary — drop in a richer sink (file, network, JSON, logback-style configuration tree) -without touching caller code. +Named loggers, levels, parameterized `{}` messages, exception attachment, +markers, MDC, and the SLF4J 2.x fluent event builder. The default +`ConsoleLogSink` writes to the platform `Console`; richer sinks (JSON, async, +fanout, hierarchical-level, file/network) plug in behind the same API. ## Documentation -All design docs live at the repo root under [`doc/logging/`](../doc/logging): - -| Doc | Purpose | -|---|---| -| [`README.md`](../doc/logging/README.md) | Start-here guide and reading paths. | -| [`lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) | Side-by-side comparison with the slog-shaped sibling library. | -| [`api-cross-reference.md`](../doc/logging/api-cross-reference.md) | Official SLF4J links mapped to each Ecstasy type and the local differences. | -| [`design.md`](../doc/logging/design/design.md) | Architecture, module layout, and API/implementation boundary. | -| [`slf4j-parity.md`](../doc/logging/usage/slf4j-parity.md) | SLF4J 2.x type and method mapping. | -| [`ecstasy-vs-java-examples.md`](../doc/logging/usage/ecstasy-vs-java-examples.md) | Java SLF4J examples next to equivalent Ecstasy code. | -| [`lazy-logging.md`](../doc/logging/usage/lazy-logging.md) | Kotlin/Java supplier-style lazy logging and the Ecstasy call shape. | -| [`custom-sinks.md`](../doc/logging/usage/custom-sinks.md) | Guide to writing a custom sink. | -| [`open-questions.md`](../doc/logging/open-questions.md) | Decision tracker and remaining runtime/compiler/backend follow-up. | +All design docs live at the repo root under [`doc/logging/`](../doc/logging). +Start at [`doc/logging/README.md`](../doc/logging/README.md) — it carries the +goals, requirements, status, reading paths, and the full doc index. diff --git a/lib_logging/src/main/x/logging/AsyncLogSink.x b/lib_logging/src/main/x/logging/AsyncLogSink.x index fc7c9136dc..b94da753ad 100644 --- a/lib_logging/src/main/x/logging/AsyncLogSink.x +++ b/lib_logging/src/main/x/logging/AsyncLogSink.x @@ -93,13 +93,47 @@ service AsyncLogSink(LogSink delegate, Int capacity) implements LogSink { /** * Drain the queue on this service's fiber. + * + * Two safety properties live here, both worth understanding before changing this code. + * + * 1. **`draining` must be reset on every exit, including exceptions.** + * The whole point of this wrapper is to insulate callers from a misbehaving + * delegate. If a delegate `log(event)` throws, we still need `draining = False` + * afterwards — otherwise `log()` will keep enqueueing events but never schedule + * another drain (because it sees `draining == True`), the queue grows to + * `capacity`, and from then on every event is silently dropped via + * `droppedCount`. The `try/finally` guarantees the flag is cleared. + * + * 2. **Per-event failure is contained.** + * `LogSink.log` is contractually non-throwing (see `LogSink.x`), but a buggy + * custom sink can still throw. We catch per event so that one bad event + * increments `droppedCount` and the rest of the batch still drains. The + * alternative — letting the exception escape and rely on (1) to recover — would + * drop every queued event behind the throwing one. That's a bigger surprise to + * operators than a single counter tick. + * + * The loop uses a swap-out batch instead of `queue.delete(0)` per event. `delete(0)` + * is O(n) on `Array`, so the original loop was O(n²) per drain. The swap takes the + * mutable buffer in one step, then iterates it; new events arriving during the inner + * loop land in a fresh `queue` and are picked up by the outer `while`. Service-fiber + * serialisation means no other `log()` call can run between our read of `queue` and + * our reassignment, so no event is lost. */ private void drain() { - while (!queue.empty) { - LogEvent event = queue[0]; - queue.delete(0); - delegate.log(event); + try { + while (!queue.empty) { + LogEvent[] batch = queue; + queue = new LogEvent[]; + for (LogEvent event : batch) { + try { + delegate.log(event); + } catch (Exception e) { + ++droppedCount; + } + } + } + } finally { + draining = False; } - draining = False; } } diff --git a/lib_logging/src/main/x/logging/BasicEventBuilder.x b/lib_logging/src/main/x/logging/BasicEventBuilder.x index e0dbef2ab6..60ba21b860 100644 --- a/lib_logging/src/main/x/logging/BasicEventBuilder.x +++ b/lib_logging/src/main/x/logging/BasicEventBuilder.x @@ -5,18 +5,29 @@ * * Default `LoggingEventBuilder` implementation. Accumulates state then forwards to the * underlying `Logger`'s `log(...)` method. + * + * # Lazy values + * + * Both positional `{}` arguments and structured key/value pairs can be supplied either + * eagerly (the value is already computed) or lazily (a function that runs only after the + * level/marker check accepts the event). Lazy entries are stored as a [LazyValue] + * wrapper inside the same `args` / `keyValues` collections — there are no parallel + * boolean flag arrays to keep in sync. The resolver at `frozenArgs()` / `frozenKVs()` + * does `value.is(LazyValue) ? lv.resolve() : value`. This is also why + * `addLazyArgument` / `addLazyKeyValue` are explicit method names rather than overloads + * of `addArgument` / `addKeyValue`: in Ecstasy a `function Object()` is itself an + * `Object`, so an overload that accepted a supplier could not be reliably distinguished + * from one that accepted the supplier as a literal value. */ class BasicEventBuilder(BasicLogger logger, Level level) implements LoggingEventBuilder { - private String? message = Null; - private MessageSupplier? lazyMessage = Null; - private Object[] args = new Object[]; - private Boolean[] lazyArgs = new Boolean[]; - private Marker[] markers = new Marker[]; - private Exception? cause = Null; - private Map keyValues = new ListMap(); - private Map lazyKeyValues = new ListMap(); + private String? message = Null; + private MessageSupplier? lazyMessage = Null; + private Object[] args = new Object[]; + private Marker[] markers = new Marker[]; + private Exception? cause = Null; + private Map keyValues = new ListMap(); /** * Replace the pending message pattern. Mirrors SLF4J @@ -45,17 +56,17 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override LoggingEventBuilder addArgument(Object value) { args.add(value); - lazyArgs.add(False); return this; } /** - * Append one lazily computed positional `{}` argument. + * Append one lazily computed positional `{}` argument. The supplier runs only after + * the sink accepts the event; see the class doc comment for why this is a separate + * method rather than an overload of `addArgument`. */ @Override LoggingEventBuilder addLazyArgument(ObjectSupplier value) { - args.add(value); - lazyArgs.add(True); + args.add(new LazyValue(value)); return this; } @@ -85,17 +96,17 @@ class BasicEventBuilder(BasicLogger logger, Level level) @Override LoggingEventBuilder addKeyValue(String key, Object value) { keyValues.put(key, value); - lazyKeyValues.put(key, False); return this; } /** - * Attach one lazily computed structured key/value pair. + * Attach one lazily computed structured key/value pair. Stored as a [LazyValue] + * inside the same `keyValues` map; resolved at `frozenKVs()` once the event is + * confirmed enabled. */ @Override LoggingEventBuilder addLazyKeyValue(String key, ObjectSupplier value) { - keyValues.put(key, value); - lazyKeyValues.put(key, True); + keyValues.put(key, new LazyValue(value)); return this; } @@ -204,9 +215,10 @@ class BasicEventBuilder(BasicLogger logger, Level level) /** * Materialise the accumulated structured key/value map as a const-mode (immutable) map - * so the resulting `LogEvent` can cross the sink boundary. SLF4J's reference impl - * realises a `KeyValuePair` list at the same point; here it's a `Map` on - * `LogEvent.keyValues`. + * so the resulting `LogEvent` can cross the sink boundary. Lazy entries (stored as + * [LazyValue] wrappers) are resolved here, after the level/marker check has confirmed + * the event will actually be emitted. SLF4J's reference impl realises a `KeyValuePair` + * list at the same point; here it's a `Map` on `LogEvent.keyValues`. */ private Map frozenKVs() { if (keyValues.empty) { @@ -214,17 +226,16 @@ class BasicEventBuilder(BasicLogger logger, Level level) } ListMap snapshot = new ListMap(); for ((String k, Object v) : keyValues) { - snapshot.put(k, lazyKeyValues.getOrDefault(k, False) - ? v.as(ObjectSupplier)() - : v); + snapshot.put(k, resolve(v)); } return snapshot.makeImmutable(); } /** * Convert the mutable accumulating buffer to a constant-mode (immutable) array so - * it can cross service boundaries on its way to the sink. Equivalent to SLF4J's - * `EventArgArray` materialisation step. + * it can cross service boundaries on its way to the sink. Lazy entries are resolved + * here for the same reason as in [frozenKVs]. Equivalent to SLF4J's `EventArgArray` + * materialisation step. */ private Object[] frozenArgs() { if (args.empty) { @@ -232,12 +243,20 @@ class BasicEventBuilder(BasicLogger logger, Level level) } Object[] snapshot = new Array(args.size); - for (Int i : 0 ..< args.size) { - Object value = args[i]; - snapshot.add(lazyArgs[i] - ? value.as(ObjectSupplier)() - : value); + for (Object value : args) { + snapshot.add(resolve(value)); } return snapshot.toArray(Constant); } + + /** + * Unwrap a [LazyValue] if present; pass plain values through unchanged. Centralises + * the discrimination so both `frozenArgs` and `frozenKVs` share one rule. + */ + private static Object resolve(Object value) { + if (LazyValue lazy := value.is(LazyValue)) { + return lazy.resolve(); + } + return value; + } } diff --git a/lib_logging/src/main/x/logging/LazyValue.x b/lib_logging/src/main/x/logging/LazyValue.x new file mode 100644 index 0000000000..804d8eae46 --- /dev/null +++ b/lib_logging/src/main/x/logging/LazyValue.x @@ -0,0 +1,36 @@ +/** + * Internal carrier for lazily-computed positional arguments and key/value pairs. + * + * The fluent builder ([LoggingEventBuilder]) needs to distinguish "this slot holds an + * already-computed value" from "this slot holds a function to invoke after the level + * check passes." Storing the supplier function directly in an `Object[]` does not work + * by itself, because in Ecstasy `function Object()` is itself an `Object`, so a runtime + * `value.is(...)` check cannot reliably tell a real `Object` value apart from a supplier + * the caller wanted invoked. Wrapping the supplier in this dedicated type makes the + * discrimination explicit: arguments and key/value pairs are stored as `Object[]` / + * `Map`; the resolver step in [BasicEventBuilder] does + * `value.is(LazyValue) ? lv.resolve() : value`. + * + * # Why this is a `class`, not a `const` + * + * The slog-shaped sibling library has the same wrapper at `slogging/LazyValue.x` and it + * *is* a `const` there, because slog's `Attr` is a `const` and so its `value` field must + * be `Passable`. Here the wrapper lives only in `BasicEventBuilder` (a `class`) and is + * resolved into a plain `Object` before crossing any service boundary, so the + * passability constraint does not apply. Crucially, making this a `class` means the + * captured supplier closure is NOT auto-frozen on construction — so a caller can write + * + * @Volatile Int calls = 0; + * builder.addLazyArgument(() -> { ++calls; return value; }).log("..."); + * + * and the `++calls` mutation works as expected. A `const` wrapper would freeze the + * closure environment and `++calls` would throw `ReadOnly` (which is exactly what the + * slog side documents as a constraint on `Attr.lazy` callers). + */ +class LazyValue(ObjectSupplier supplier) { + + /** + * Evaluate the deferred value. + */ + Object resolve() = supplier(); +} diff --git a/lib_logging/src/main/x/logging/MDC.x b/lib_logging/src/main/x/logging/MDC.x index 6c1039fac3..c0e6b383aa 100644 --- a/lib_logging/src/main/x/logging/MDC.x +++ b/lib_logging/src/main/x/logging/MDC.x @@ -78,13 +78,23 @@ const MDC { /** * Store a value under `key`. Setting `Null` removes the key. + * + * Skips the copy-on-write derivation when the existing entry already matches the + * incoming value. Each MDC mutation otherwise costs one fresh `HashMap`-build + + * `makeImmutable` + `SharedContext.withValue` token; the no-op fast path matters in + * code paths that reassert request context on every call (a common pattern in + * cross-cutting middleware that re-`put`s `requestId` defensively). Symmetrical to + * the existing fast path in [remove]. */ void put(String key, String? value) { if (value == Null) { remove(key); - } else { - mapContext.withValue(derive(key, key, value)); + return; } + if (String existing := currentMap().get(key), existing == value) { + return; + } + mapContext.withValue(derive(key, key, value)); } /** diff --git a/lib_logging/src/main/x/logging/MarkerFactory.x b/lib_logging/src/main/x/logging/MarkerFactory.x index 6779f58b72..ecc8197f29 100644 --- a/lib_logging/src/main/x/logging/MarkerFactory.x +++ b/lib_logging/src/main/x/logging/MarkerFactory.x @@ -5,21 +5,52 @@ * * Factory for `Marker` instances. Mirrors `org.slf4j.MarkerFactory`. * - * `getMarker(name)` is _interning_: two calls with the same name return the same instance. - * `getDetachedMarker(name)` always returns a fresh instance, useful when a caller wants a - * marker that is not visible to other callers. + * `getMarker(name)` is _interning_: two calls with the same name return the same + * instance. `getDetachedMarker(name)` always returns a fresh instance, useful when a + * caller wants a marker that is not visible to other callers. * - * This is intentionally a stateful `class`, not a `service`: markers are mutable while a - * caller builds a marker DAG, and service-boundary passing would freeze/copy them before - * the caller could rely on SLF4J-style identity. + * # Why a `class`, not a `service` + * + * The natural Ecstasy answer to "make this thread-safe" is `service`. We deliberately + * do not pick that here, because of an interaction between three facts: + * + * - `BasicMarker` is a *mutable* `class` — `add(child)` builds the DAG in place. + * - The SLF4J idiom is `factory.getMarker("X").add(child)`, which mutates the + * interned marker directly after retrieval. + * - Ecstasy auto-freezes any `Freezable` value that crosses a service boundary on + * the way out. + * + * Together, those rules mean a `service MarkerFactory.getMarker(...)` would return a + * frozen `BasicMarker` to its caller, and the next `marker.add(...)` call would throw + * `ReadOnly`. That contradicts the SLF4J pattern and breaks every existing test and + * `manualTests/TestLogging.x` call site. + * + * # Thread-safety contract + * + * Because this is a `class` with a mutable internal `HashMap`, **a single + * `MarkerFactory` instance is not safe to share across fibers**. The supported pattern + * is one factory per component (per service, per request, per test). The existing + * test suite (`MarkerFactoryTest`, `MarkerTest`) and `manualTests/TestLogging.x` follow + * this pattern: each scope constructs its own factory. + * + * If a future user genuinely needs a process-global, fiber-safe marker registry, the + * right shape is a separate `service MarkerRegistry` whose API folds DAG mutation + * into service methods (e.g. `addReference(parentName, childName)`) so the in-service + * `BasicMarker` instances stay live and never cross the freeze boundary. That is + * tracked in `doc/logging/roadmap.md` rather than retrofitted onto this type, because + * collapsing both shapes into one type either gives up the SLF4J ergonomics here or + * gives up safe global sharing there. */ class MarkerFactory { private Map markers = new HashMap(); /** - * Get (creating if necessary) the canonical marker with the supplied name. Subsequent calls - * with the same `name` return the same marker. + * Get (creating if necessary) the canonical marker with the supplied name. + * Subsequent calls with the same `name` return the same marker. + * + * Thread safety: the underlying `HashMap.computeIfAbsent` is not concurrent. Use + * one `MarkerFactory` per fiber/component (see the class doc comment). */ Marker getMarker(String name) { return markers.computeIfAbsent(name, () -> new BasicMarker(name)); diff --git a/lib_slogging/README.md b/lib_slogging/README.md index 7ea8d9bbd5..3260d4f73a 100644 --- a/lib_slogging/README.md +++ b/lib_slogging/README.md @@ -1,23 +1,8 @@ # lib_slogging -Experimental Go `log/slog`-shaped structured logging library for Ecstasy. - -> **Status:** Working comparison POC. The core `Logger` / `Handler` / `Record` / -> `Attr` / `Level` shape, text/JSON/no-op/memory handlers, derived loggers, groups, -> custom levels, lazy message/attr suppliers, source metadata, `LoggerContext`, -> runtime injection, async handler wrapping, handler options/redaction, and unit tests -> are in this branch. -> `JSONHandler` renders through `lib_json`. - -> **POC naming note:** this module exists beside `lib_logging` only so the branch can -> compare two API shapes. The XDK should eventually ship one canonical injectable -> logging library named `lib_logging`; `lib_slogging` is not intended as a permanent -> second default facade. - -## What this is - -`lib_slogging` is the attribute-first alternative to the SLF4J-shaped -[`lib_logging`](../lib_logging). It is familiar to Go engineers: +Go `log/slog`-shaped structured logging library for Ecstasy. Comparison POC +that lives beside [`lib_logging`](../lib_logging) so reviewers can pick between +two API shapes: ```ecstasy Logger logger = new Logger(new TextHandler()); @@ -31,30 +16,18 @@ requestLog.info("payment processed", [ Attr.of("amount", amount), Attr.of("currency", currency), ]); - -requestLog.debug("payload", [ - Attr.lazy("json", () -> serializer.dump(payload)), -]); ``` -There are no markers and no MDC in the base shape. Categorisation, errors, and -structured payload all travel as `Attr` values. Request context is normally explicit -(`logger.with([...])`), with `LoggerContext` available when framework code needs -implicit propagation. Backends implement the small `Handler` interface. - -## Documentation +Attribute-first events: no markers, no MDC, no `{}` interpolation in the base +shape. Categorisation, errors, and structured payload all travel as `Attr` +values. Backends implement the small `Handler` interface. -Start with the repo-level docs: +> **POC name.** This module exists only to compare the slog shape against the +> SLF4J shape. Whichever wins ships as `lib_logging`; this module is not +> intended as a permanent second facade. -| Doc | Purpose | -|---|---| -| [`doc/logging/README.md`](../doc/logging/README.md) | Start-here guide and reading paths. | -| [`doc/logging/lib-logging-vs-lib-slogging.md`](../doc/logging/lib-logging-vs-lib-slogging.md) | Side-by-side design comparison and reviewer questions. | -| [`doc/logging/api-cross-reference.md`](../doc/logging/api-cross-reference.md) | Official Go `log/slog` links mapped to each Ecstasy type and the local differences. | -| [`doc/logging/usage/slog-parity.md`](../doc/logging/usage/slog-parity.md) | Go `log/slog` method/type mapping and intentional Ecstasy differences. | -| [`doc/logging/usage/lazy-logging.md`](../doc/logging/usage/lazy-logging.md) | Lazy message and attribute construction in both POC APIs. | -| [`doc/logging/usage/custom-handlers.md`](../doc/logging/usage/custom-handlers.md) | Guide to writing custom handlers. | -| [`doc/logging/cloud-integration.md`](../doc/logging/cloud-integration.md) | How the API shape maps to cloud logging systems. | +## Documentation -The source files under [`src/main/x/slogging/`](src/main/x/slogging/) are intentionally -small and each names its Go `log/slog` counterpart in the doc comment. +All design docs live at the repo root under [`doc/logging/`](../doc/logging). +Start at [`doc/logging/README.md`](../doc/logging/README.md) for goals, +requirements, status, reading paths, and the full doc index. diff --git a/lib_slogging/src/main/x/slogging/AsyncHandler.x b/lib_slogging/src/main/x/slogging/AsyncHandler.x index 1bd992a9de..24c6ff84df 100644 --- a/lib_slogging/src/main/x/slogging/AsyncHandler.x +++ b/lib_slogging/src/main/x/slogging/AsyncHandler.x @@ -81,13 +81,35 @@ service AsyncHandler(Handler delegate, Int capacity) /** * Drain the queue on this service's fiber. + * + * The same two safety properties as `lib_logging.AsyncLogSink.drain` apply here — + * see that file's `drain` doc-comment for the full rationale. Briefly: + * + * 1. `draining` must be cleared on every exit path (`try/finally`) so a delegate + * exception cannot strand the wrapper in the "drain already scheduled" state, + * after which `handle()` would silently drop everything via `droppedCount`. + * 2. Per-record failure is contained so one bad record does not prevent the rest + * of the batch from draining. + * + * The swap-out batch (`batch = queue; queue = new Record[]`) replaces the original + * O(n) `queue.delete(0)` per record. Service-fiber serialisation guarantees the + * swap is atomic with respect to other `handle()` calls. */ private void drain() { - while (!queue.empty) { - Record record = queue[0]; - queue.delete(0); - delegate.handle(record); + try { + while (!queue.empty) { + Record[] batch = queue; + queue = new Record[]; + for (Record record : batch) { + try { + delegate.handle(record); + } catch (Exception e) { + ++droppedCount; + } + } + } + } finally { + draining = False; } - draining = False; } } From 8cc7fb51195a1dfdd08a8a4a6ee534e98fb27205 Mon Sep 17 00:00:00 2001 From: Marcus Lagergren <1062473+lagergren@users.noreply.github.com> Date: Tue, 5 May 2026 13:19:34 +0200 Subject: [PATCH 23/28] Explain MDC before the example that uses it The "smallest possible app" doesn't need MDC; the "more realistic app" suddenly calls mdc.put/remove/clear with no preamble. Add a section between the two that defines MDC, points at the Java analogues (Logback MDC, Log4j 2 ThreadContext), and lays out the three rules a reader needs at the call site: per-fiber scope, sinks read while callers write, clear at the request boundary. --- doc/logging/usage/injected-logger-example.md | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/doc/logging/usage/injected-logger-example.md b/doc/logging/usage/injected-logger-example.md index d942dd3c59..f40973992a 100644 --- a/doc/logging/usage/injected-logger-example.md +++ b/doc/logging/usage/injected-logger-example.md @@ -72,6 +72,46 @@ emits the line to the platform `Console`. Output: 2026-04-29T11:23:45.012Z [] INFO logger: hello, world ``` +## What MDC is, before we use it + +Mapped Diagnostic Context — `MDC` for short — is a small per-fiber string→string +scratchpad that travels with the calling fiber but does not appear in any +function signature. The pattern is: at a request boundary, the framework writes +a few keys (`requestId`, `user`, `tenant`); every subsequent log call inside +that request automatically carries them; at the end of the request the +framework clears them. Without MDC you would either thread `requestId` through +every method as an argument, or lose it. + +It is the same idea as Logback's `MDC` and Log4j 2's `ThreadContext`. + +```ecstasy +@Inject MDC mdc; + +mdc.put("requestId", "r_42"); +logger.info("processing"); // sink sees requestId=r_42 in the event +logger.info("validated"); // same +mdc.clear(); // request boundary +``` + +Three things to know: + +1. **Scope is the calling fiber.** Bindings flow into child fibers spawned + from this one, but mutations from a child do not leak back to the parent. + This is the right propagation for request handlers — child workers see the + parent's context but cannot corrupt it. +2. **Sinks read it, callers write it.** A sink decides whether to render MDC + at all (the JSON sink emits it as `{"mdc": {...}}`; the console sink + appends `[mdc=k=v,…]`); the calling code never has to remember which + sinks care. +3. **Clear at the boundary.** Long-lived fibers should `mdc.remove(key)` or + `mdc.clear()` at the end of the unit of work, otherwise context bleeds + into unrelated events. + +When MDC is *not* the right tool: if the value is data the call needs, pass it +as an argument or a structured key/value pair. MDC is for cross-cutting +identifiers that every event in a request should carry without the calling +code having to know. + ## A more realistic app The example below is closer to what real code looks like. It demonstrates: @@ -79,7 +119,7 @@ The example below is closer to what real code looks like. It demonstrates: - per-level emission with parameterized messages; - attaching an exception via the `cause` parameter; - using a marker for categorical routing; -- using MDC for request-scoped context; +- using MDC for request-scoped context (per the section above); - the SLF4J 2.x fluent event builder. ```ecstasy From 16ac67947f4a9d879fad21d00778802e6dc020b2 Mon Sep 17 00:00:00 2001 From: Jonathan Knight Date: Thu, 7 May 2026 15:08:40 +0300 Subject: [PATCH 24/28] Removed `Attrs.x` as this is just a String key and any value pair. Used a Map to represent attributes instead. Refactored various types to remove their multiple constructors and use default parameter values instead. --- lib_slogging/src/main/x/slogging.x | 42 ++++++-- .../src/main/x/slogging/AsyncHandler.x | 13 +-- lib_slogging/src/main/x/slogging/Attr.x | 96 ------------------- .../src/main/x/slogging/BoundHandler.x | 66 ++++++------- lib_slogging/src/main/x/slogging/Handler.x | 10 +- .../src/main/x/slogging/HandlerOptions.x | 14 +-- .../src/main/x/slogging/JSONHandler.x | 75 +++++++-------- lib_slogging/src/main/x/slogging/LazyValue.x | 14 --- lib_slogging/src/main/x/slogging/Logger.x | 90 ++++++++--------- .../src/main/x/slogging/LoggerContext.x | 2 +- .../src/main/x/slogging/MemoryHandler.x | 10 +- lib_slogging/src/main/x/slogging/NopHandler.x | 2 +- lib_slogging/src/main/x/slogging/Record.x | 4 +- .../src/main/x/slogging/TextHandler.x | 47 ++++----- lib_slogging/src/test/x/SLoggingTest.x | 2 +- .../src/test/x/SLoggingTest/AttrTest.x | 42 -------- .../src/test/x/SLoggingTest/CountingHandler.x | 8 +- .../src/test/x/SLoggingTest/EmissionTest.x | 7 +- .../src/test/x/SLoggingTest/HandlerContract.x | 33 ++++--- .../x/SLoggingTest/HandlerDerivationTest.x | 8 +- .../src/test/x/SLoggingTest/JSONHandlerTest.x | 20 ++-- .../src/test/x/SLoggingTest/LazyCounter.x | 12 --- .../src/test/x/SLoggingTest/LazyLoggingTest.x | 40 +------- .../src/test/x/SLoggingTest/LevelCheckTest.x | 1 - .../src/test/x/SLoggingTest/ListHandler.x | 8 +- .../test/x/SLoggingTest/LoggerContextTest.x | 7 +- .../test/x/SLoggingTest/MemoryHandlerTest.x | 5 +- .../test/x/SLoggingTest/SourceLocationTest.x | 5 +- .../src/test/x/SLoggingTest/TrackingHandler.x | 14 +-- .../src/test/x/SLoggingTest/WithGroupTest.x | 39 ++++---- .../src/test/x/SLoggingTest/WithTest.x | 39 ++++---- 31 files changed, 276 insertions(+), 499 deletions(-) delete mode 100644 lib_slogging/src/main/x/slogging/Attr.x delete mode 100644 lib_slogging/src/main/x/slogging/LazyValue.x delete mode 100644 lib_slogging/src/test/x/SLoggingTest/AttrTest.x delete mode 100644 lib_slogging/src/test/x/SLoggingTest/LazyCounter.x diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index 59a06a515a..0eb188bbde 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -21,15 +21,15 @@ * * Derived loggers are pure construction: * - * Logger requestLogger = logger.with([ - * Attr.of("requestId", req.id), - * Attr.of("user", req.userId), + * Logger requestLogger = logger.with(Map:[ + * "requestId" = req.id, + * "user" = req.userId, * ]); * * # Status * * **This module is a working comparison POC.** The core API, level checks, derived - * loggers, groups, custom levels, lazy message/attr suppliers, runtime injection, source + * loggers, groups, custom levels, lazy message suppliers, runtime injection, source * metadata, context binding, and memory/text/JSON/no-op handlers are implemented and * covered by unit tests. * @@ -38,7 +38,7 @@ * The public API consists of: * - [Logger] — the user-facing concrete `const` * - [Level] — open-ended severity (Int + String label) - * - [Attr] — single key/value pair carried as structured data + * - [Attributes] — typedef for `Map` carried as structured data * - [Record] — immutable record of a single log call (LogEvent equivalent) * - [Handler] — the SPI that backends implement (LogSink equivalent) * - [LoggerContext] — optional SharedContext helper for request-scoped loggers @@ -72,10 +72,34 @@ module slogging.xtclang.org { typedef function String() as MessageSupplier; /** - * Lazy attribute value supplier used by [Attr.lazy]. + * AnyValue is used to represent various values of specific types in log message attributes. * - * This mirrors Go slog's `LogValuer` idea: expensive structured values can be - * represented at the call site without constructing them for disabled records. + * An AnyValue is either: + * + * - a primitive type: string, boolean, double precision floating point (IEEE 754-1985), or + * signed 64 bit integer, + * - a homogeneous array of primitive type values. A homogeneous array MUST NOT contain values + * of different types. + * - a byte array. + * - an array of AnyValue, + * - a Mmp, + * - a Null value + * + * Arbitrary deep nesting of values for arrays and maps is allowed (essentially allows to + * represent an equivalent of a JSON object). + * Using array and map values may carry a higher performance overhead compared to primitive + * values. + */ + typedef PrimitiveValue | AnyValue[] | Map as AnyValue; + + /** + * The various primitive values allowed in an AnyValue. + */ + typedef Nullable | Boolean | String | Int | IntLiteral | Float | FPLiteral | Byte[] + as PrimitiveValue; + + /** + * A map of attribute key-value pairs carried by resources, scopes, and data points. */ - typedef function Object() as ObjectSupplier; + typedef Map as Attributes; } diff --git a/lib_slogging/src/main/x/slogging/AsyncHandler.x b/lib_slogging/src/main/x/slogging/AsyncHandler.x index 24c6ff84df..b7b0eec1bd 100644 --- a/lib_slogging/src/main/x/slogging/AsyncHandler.x +++ b/lib_slogging/src/main/x/slogging/AsyncHandler.x @@ -5,17 +5,10 @@ * preserves attrs, groups, source metadata, and exceptions exactly as the caller * produced them. */ -service AsyncHandler(Handler delegate, Int capacity) +service AsyncHandler(Handler delegate, Int capacity = 1024) implements Handler { - /** - * Convenience: bounded queue with room for 1024 records. - */ - construct(Handler delegate) { - construct AsyncHandler(delegate, 1024); - } - - private Record[] queue = new Record[]; + private Record[] queue = new Record[]; private Boolean draining = False; private Boolean closed = False; @@ -49,7 +42,7 @@ service AsyncHandler(Handler delegate, Int capacity) } @Override - Handler withAttrs(Attr[] attrs) { + Handler withAttrs(Attributes attrs) { return attrs.empty ? this : new AsyncHandler(delegate.withAttrs(attrs), capacity); } diff --git a/lib_slogging/src/main/x/slogging/Attr.x b/lib_slogging/src/main/x/slogging/Attr.x deleted file mode 100644 index aa232419b8..0000000000 --- a/lib_slogging/src/main/x/slogging/Attr.x +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Corresponds to `log/slog.Attr` (`go.dev/src/log/slog/attr.go`). The single carrier - * for structured data attached to a log record. - * - * Every piece of structured data — what SLF4J would split across `arguments`, `marker`, - * and `keyValues` — is expressed as one of these. `Logger.with(...)` asks the handler - * to derive a new handler with "always include these" attributes; the per-call - * `info(message, extra)` adds more for that call only. - * - * Attr.of("user", "alice") - * Attr.of("count", 42) - * Attr.of("audit", True) - * Attr.of("err", e) - * - * # Value typing - * - * Go's `slog.Value` is a closed discriminated union (`KindString`, `KindInt64`, - * `KindBool`, `KindDuration`, `KindTime`, `KindAny`, `KindGroup`, `KindLogValuer`). - * In Ecstasy we accept any `Object` here and let the handler do the case split via - * `value.is(...)`. This is closer to how the SLF4J library carries `Map` - * and avoids inventing a parallel kind-tag enum. The trade-off — slightly more work in - * each handler — is documented in `lib-logging-vs-lib-slogging.md` § 3.5. - * - * Values must be `Passable` (`immutable` or a service) because `Attr` is a `const` and - * its fields are auto-frozen on construction. Trying to put a mutable class instance in - * here will fail with the standard "not freezable" diagnostic. - * - * # Lazy values - * - * Use [lazy] for expensive per-record values: - * - * logger.debug("payload", [Attr.lazy("json", () -> serialize(payload))]); - * - * The logger resolves lazy values only after [Handler.enabled] accepts the record. This - * is the Ecstasy POC's equivalent of Go slog values that implement `LogValuer`. - * - * # Groups - * - * Setting `value` to an `Attr[]` represents a nested group, equivalent to slog's - * `slog.Group("user", slog.String("id", "u_1"), slog.String("role", "admin"))`. The - * handler decides how to render groups (JSON nests, text concatenates with dots). - */ -const Attr(String key, Object value) { - - /** - * Convenience factory. Mirrors slog's `slog.String("k", v)` / `slog.Int(...)` — - * the per-type Go helpers exist there because Go has no generic / `Object` parent - * type; in Ecstasy a single `of` is sufficient because everything inherits `Object`. - */ - static Attr of(String key, Object value) = new Attr(key, value); - - /** - * Construct a nested group attribute. Equivalent to slog's - * `slog.Group(name, attrs...)`. - */ - static Attr group(String name, Attr[] attrs) = new Attr(name, attrs); - - /** - * Construct an attribute whose value is computed lazily after the level check. - * - * Prefer this for expensive per-call data. Context attrs passed to `Logger.with(...)` - * should normally be stable eager values; if they are lazy, [BoundHandler] resolves - * them when an enabled record is handled. - */ - static Attr lazy(String key, ObjectSupplier value) = new Attr(key, new LazyValue(value)); - - /** - * Return this attr with any lazy value resolved. Nested groups are resolved - * recursively so handlers see ordinary values. - */ - Attr resolved() { - if (LazyValue lazy := value.is(LazyValue)) { - return new Attr(key, lazy.resolve()); - } - if (Attr[] attrs := value.is(Attr[])) { - return Attr.group(key, resolveAll(attrs)); - } - return this; - } - - /** - * Resolve lazy values in an attr array, returning an immutable array suitable for - * a [Record] or handler boundary. - */ - static Attr[] resolveAll(Attr[] attrs) { - if (attrs.empty) { - return []; - } - - Attr[] resolved = new Array(attrs.size); - for (Attr attr : attrs) { - resolved.add(attr.resolved()); - } - return resolved.toArray(Constant); - } -} diff --git a/lib_slogging/src/main/x/slogging/BoundHandler.x b/lib_slogging/src/main/x/slogging/BoundHandler.x index 1a37c4dccc..0cde7d3968 100644 --- a/lib_slogging/src/main/x/slogging/BoundHandler.x +++ b/lib_slogging/src/main/x/slogging/BoundHandler.x @@ -10,35 +10,21 @@ * * The wrapper composes in the same order as Go slog: * - * logger.with([Attr.of("env", "prod")]) + * logger.with(Map:["env"="prod"]) * .withGroup("payments") - * .info("charged", [Attr.of("amount", 1099)]); + * .info("charged", Map:["amount"=1099]); * * yields `env=prod` outside the `payments` group, while: * * logger.withGroup("payments") - * .with([Attr.of("env", "prod")]) - * .info("charged", [Attr.of("amount", 1099)]); + * .with(Map:["env"="prod"]) + * .info("charged", Map:["amount"=1099]); * * yields both `env` and `amount` inside the `payments` group. */ -const BoundHandler(Handler delegate, Attr[] attrs, String? groupName) +const BoundHandler(Handler delegate, Attributes attrs = [], String? groupName = Null) implements Handler { - /** - * Derive a handler with pre-bound attributes. - */ - construct(Handler delegate, Attr[] attrs) { - construct BoundHandler(delegate, attrs.toArray(Constant), Null); - } - - /** - * Derive a handler that groups subsequent attributes under `groupName`. - */ - construct(Handler delegate, String groupName) { - construct BoundHandler(delegate, [], groupName); - } - /** * Fast-path level check. Bound attrs and groups never affect enablement. */ @@ -46,16 +32,17 @@ const BoundHandler(Handler delegate, Attr[] attrs, String? groupName) Boolean enabled(Level level) = delegate.enabled(level); /** - * Apply the bound attrs/group to the record, resolve any [Attr.lazy] values now - * that the logger has already passed the level check, then forward to the delegate. + * Apply the bound attrs/group to the record, then forward to the delegate. */ @Override void handle(Record record) { - Attr[] merged = merge(Attr.resolveAll(attrs), Attr.resolveAll(record.attrs)); + Attributes merged = merge(attrs, record.attrs); if (String group ?= groupName) { - merged = merged.empty - ? [] - : [Attr.group(group, merged)].toArray(Constant); + if (!merged.empty) { + ListMap wrapped = new ListMap(); + wrapped.put(group, merged); + merged = wrapped.makeImmutable(); + } } delegate.handle(new Record( @@ -76,8 +63,8 @@ const BoundHandler(Handler delegate, Attr[] attrs, String? groupName) * `WithGroup(...).With(...)`. */ @Override - Handler withAttrs(Attr[] more) { - return more.empty ? this : new BoundHandler(this, more); + Handler withAttrs(Attributes attrs) { + return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); } /** @@ -85,17 +72,26 @@ const BoundHandler(Handler delegate, Attr[] attrs, String? groupName) */ @Override Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(this, name); + return name == "" ? this : new BoundHandler(delegate=this, groupName=name); } /** - * Immutable concatenation helper for passable records. + * Immutable concatenation helper preserving insertion order. */ - private Attr[] merge(Attr[] first, Attr[] second) { - return first.empty - ? second.toArray(Constant) - : second.empty - ? first.toArray(Constant) - : (first + second).toArray(Constant); + private Attributes merge(Attributes first, Attributes second) { + if (first.empty) { + return second; + } + if (second.empty) { + return first; + } + ListMap result = new ListMap(first.size + second.size); + for ((String key, AnyValue value) : first) { + result.put(key, value); + } + for ((String key, AnyValue value) : second) { + result.put(key, value); + } + return result.makeImmutable(); } } diff --git a/lib_slogging/src/main/x/slogging/Handler.x b/lib_slogging/src/main/x/slogging/Handler.x index bcb402b1a6..51fcd85379 100644 --- a/lib_slogging/src/main/x/slogging/Handler.x +++ b/lib_slogging/src/main/x/slogging/Handler.x @@ -6,10 +6,10 @@ * * # The contract * - * Boolean enabled(Level level) — cheap fast-path filter - * void handle(Record record) — emit the record - * Handler withAttrs(Attr[] attrs) — derive a pre-bound handler - * Handler withGroup(String name) — derive a name-prefixed handler + * Boolean enabled(Level level) — cheap fast-path filter + * void handle(Record record) — emit the record + * Handler withAttrs(Attributes attrs) — derive a pre-bound handler + * Handler withGroup(String name) — derive a name-prefixed handler * * The two extra methods exist so that when user code does `logger.with(attrs)` once and * then logs millions of records through the derived logger, the handler can do the @@ -57,7 +57,7 @@ interface Handler { * [BoundHandler] for the default semantics, or return a handler-specific derived * instance that caches a rendered prefix. */ - Handler withAttrs(Attr[] attrs); + Handler withAttrs(Attributes attrs); /** * Return a handler that namespaces subsequent attributes under the supplied group diff --git a/lib_slogging/src/main/x/slogging/HandlerOptions.x b/lib_slogging/src/main/x/slogging/HandlerOptions.x index 613d327873..c66ce4cbf5 100644 --- a/lib_slogging/src/main/x/slogging/HandlerOptions.x +++ b/lib_slogging/src/main/x/slogging/HandlerOptions.x @@ -1,7 +1,7 @@ /** * Shared production options for text/JSON handlers. * - * The slog API keeps caller semantics in `Logger`/`Record`/`Attr`; formatting policy + * The slog API keeps caller semantics in `Logger`/`Record`/`Attributes`; formatting policy * belongs in handlers. This const captures the first production knobs without turning * the POC into a full configuration system. */ @@ -44,17 +44,7 @@ const HandlerOptions( String exceptionKey, ) { - construct() { - construct HandlerOptions(Level.Info, [], "***", True, - "time", "level", "msg", "source", "exception"); - } - - construct(Level rootLevel) { - construct HandlerOptions(rootLevel, [], "***", True, - "time", "level", "msg", "source", "exception"); - } - - construct(Level rootLevel, String[] redactedKeys) { + construct(Level rootLevel = Info, String[] redactedKeys = []) { construct HandlerOptions(rootLevel, redactedKeys, "***", True, "time", "level", "msg", "source", "exception"); } diff --git a/lib_slogging/src/main/x/slogging/JSONHandler.x b/lib_slogging/src/main/x/slogging/JSONHandler.x index 0f601da3fd..ab3c2f2504 100644 --- a/lib_slogging/src/main/x/slogging/JSONHandler.x +++ b/lib_slogging/src/main/x/slogging/JSONHandler.x @@ -11,31 +11,30 @@ import json.Printer; * source metadata is emitted under `"source"`, and exceptions are represented * structurally. */ -const JSONHandler(HandlerOptions options, String groupPrefix) +const JSONHandler implements Handler { /** - * No-arg convenience. See parallel comment on `lib_slogging.TextHandler`. + * Create a [JSONHandler]. + * + * @param options (optional) the handler's options + * @param groupName (optional) the handler's group name + * @param handler (optional) the handler that will process the [JsonObject] produced from the + * log [Record] (the default will print the json to the console) */ - construct() { - construct JSONHandler(new HandlerOptions(), ""); + construct (HandlerOptions? options = Null, String groupName = "", JsonHandler? handler = Null) { + this.options = options ?: new HandlerOptions(); + this.groupName = groupName; + this.handler = handler ?: defaultHandler; } - /** - * Single-arg convenience: configurable threshold, no group prefix. - */ - construct(Level rootLevel) { - construct JSONHandler(new HandlerOptions(rootLevel), ""); - } + typedef function void (JsonObject) as JsonHandler; - /** - * Production-options convenience. - */ - construct(HandlerOptions options) { - construct JSONHandler(options, ""); - } + HandlerOptions options; + + String groupName; - @Inject Console console; + JsonHandler handler; /** * Cheap threshold check; no JSON work happens for disabled records. @@ -50,15 +49,12 @@ const JSONHandler(HandlerOptions options, String groupPrefix) */ @Override void handle(Record record) { - console.print(render(record)); + handler(toJson(record)); } - /** - * Render a record to a compact JSON string. Exposed so tests and custom handlers can - * verify the JSON shape without intercepting `Console`. - */ - String render(Record record) { - return Printer.DEFAULT.render(toJson(record)); + private static void defaultHandler(JsonObject obj) { + @Inject Console console; + console.print(Printer.DEFAULT.render(obj)); } /** @@ -71,7 +67,7 @@ const JSONHandler(HandlerOptions options, String groupPrefix) obj.put(options.levelKey, record.level.label); obj.put(options.messageKey, record.message); - JsonObject attrTarget = groupPrefix == "" ? obj : ensureObject(obj, groupPrefix); + JsonObject attrTarget = groupName == "" ? obj : ensureObject(obj, groupName); addAttrs(attrTarget, record.attrs); if (Exception e ?= record.exception) { @@ -102,8 +98,8 @@ const JSONHandler(HandlerOptions options, String groupPrefix) * prefix instead. */ @Override - Handler withAttrs(Attr[] attrs) { - return attrs.empty ? this : new BoundHandler(this, attrs); + Handler withAttrs(Attributes attrs) { + return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); } /** @@ -111,22 +107,22 @@ const JSONHandler(HandlerOptions options, String groupPrefix) */ @Override Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(this, name); + return name == "" ? this : new BoundHandler(delegate=this, groupName=name); } /** * Add all attrs into a JSON object, preserving slog groups as nested objects. */ - private void addAttrs(JsonObject obj, Attr[] attrs) { - for (Attr a : attrs) { - obj.put(a.key, options.redacts(a.key) ? options.redaction : attrValue(a.value)); + private void addAttrs(JsonObject obj, Attributes attrs) { + for ((String key, AnyValue value) : attrs) { + obj.put(key, options.redacts(key) ? options.redaction : attrValue(value)); } } /** * Convert an attribute value to a JSON document. */ - private Doc attrValue(Object value) { + private Doc attrValue(AnyValue value) { if (String s := value.is(String)) { return s; } @@ -145,13 +141,10 @@ const JSONHandler(HandlerOptions options, String groupPrefix) if (FPNumber n := value.is(FPNumber)) { return n.toFPLiteral(); } - if (Attr[] attrs := value.is(Attr[])) { - JsonObject nested = json.newObject(); - addAttrs(nested, attrs); - return nested.makeImmutable(); - } - if (Exception e := value.is(Exception)) { - return exceptionJson(e); + if (Map nested := value.is(Map)) { + JsonObject obj = json.newObject(); + addAttrs(obj, nested); + return obj.makeImmutable(); } return value.toString(); @@ -172,8 +165,8 @@ const JSONHandler(HandlerOptions options, String groupPrefix) } /** - * Ensure a nested object path exists. Used only for the compatibility `groupPrefix` - * constructor; normal grouping flows through [BoundHandler] as `Attr.group(...)`. + * Ensure a nested object path exists. Used only for the compatibility `groupName` + * constructor; normal grouping flows through [BoundHandler] as nested attribute maps. */ private JsonObject ensureObject(JsonObject root, String path) { JsonObject target = root; diff --git a/lib_slogging/src/main/x/slogging/LazyValue.x b/lib_slogging/src/main/x/slogging/LazyValue.x deleted file mode 100644 index f2bd478819..0000000000 --- a/lib_slogging/src/main/x/slogging/LazyValue.x +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Internal carrier for [Attr.lazy] values. - * - * Go slog exposes this idea as the `LogValuer` interface on values. The POC keeps the - * public call shape smaller: callers create `Attr.lazy("key", () -> value)`, and the - * logger/handler boundary resolves this wrapper after the level check accepts the record. - */ -const LazyValue(ObjectSupplier supplier) { - - /** - * Evaluate the deferred value. - */ - Object resolve() = supplier(); -} diff --git a/lib_slogging/src/main/x/slogging/Logger.x b/lib_slogging/src/main/x/slogging/Logger.x index 83e366a747..916939f270 100644 --- a/lib_slogging/src/main/x/slogging/Logger.x +++ b/lib_slogging/src/main/x/slogging/Logger.x @@ -15,20 +15,20 @@ * * # The user-facing API * - * logger.debug("computed", [Attr.of("ms", 12)]); - * logger.debug(() -> expensiveMessage(), [Attr.lazy("payload", () -> payloadJson)]); - * logger.info("processing", [Attr.of("path", req.path)]); - * logger.warn("retrying", [Attr.of("attempt", 3)]); - * logger.error("failed", [Attr.of("err", e)]); - * logger.log(NOTICE, "user signed in", [...]); + * logger.debug("computed", Map:["ms"=12]); + * logger.debug(() -> expensiveMessage()); + * logger.info("processing", Map:["path"=req.path]); + * logger.warn("retrying", Map:["attempt"=3]); + * logger.error("failed", cause=e); + * logger.log(NOTICE, "user signed in"); * - * Logger reqLog = logger.with([ - * Attr.of("requestId", req.id), - * Attr.of("user", req.userId), + * Logger reqLog = logger.with(Map:[ + * "requestId" = req.id, + * "user" = req.userId, * ]); * * Logger groupLog = logger.withGroup("payments"); - * groupLog.info("charged", [Attr.of("amount", 1099)]); + * groupLog.info("charged", Map:["amount"=1099]); * // text handler: 2026-04-29 INFO charged payments.amount=1099 * // json handler: {"level":"INFO","msg":"charged","payments":{"amount":1099}} * @@ -41,35 +41,25 @@ * `LogAttrs(ctx, level, msg, attrs...)` form; [LoggerContext] is the Ecstasy-shaped * optional context helper. */ -const Logger(Handler handler) +const Logger implements Orderable { @Inject Clock clock; - /** - * Convenience: no-arg, wires a fresh [TextHandler] and empty attrs. This is the - * constructor the runtime would call for `@Inject Logger logger;` once the native - * resource supplier is registered (parallel to the SLF4J library's - * `NativeContainer.ensureLogger`). - * - * Explicit delegating constructors (rather than `attrs = []` default on the - * primary form) for the same reason as the SLF4J side: cross-module - * default-argument resolution on `const` constructors does not always synthesise - * the shorter form. See `lib_logging.BasicLogger`. - */ - construct() { - construct Logger(new TextHandler()); - } - /** * Compatibility convenience: handler plus pre-bound attrs. The attrs are handed to * [Handler.withAttrs] immediately, which is the slog contract and what allows a * handler to pre-render or cache its own derived state. */ - construct(Handler handler, Attr[] attrs) { - construct Logger(attrs.empty ? handler : handler.withAttrs(attrs)); + construct(Handler? handler = Null, Attributes attrs = []) { + this.handler = handler ?: new TextHandler(); + if (!attrs.empty) { + this.handler = this.handler.withAttrs(attrs); + } } + Handler handler; + /** * Cheap `Debug` enabled check. Mirrors Go's `Logger.Enabled(ctx, slog.LevelDebug)` * and the SLF4J-shaped library's `debugEnabled` property. @@ -78,7 +68,7 @@ const Logger(Handler handler) /** * Cheap `Info` enabled check. Use for multi-statement guarded work; for one-line - * expensive values, prefer `logger.info(() -> "...", [Attr.lazy("k", () -> v)])`. + * expensive values, prefer `logger.info(() -> "...")`. */ @RO Boolean infoEnabled.get() = handler.enabled(Level.Info); @@ -103,50 +93,49 @@ const Logger(Handler handler) * structured values belong in `extra` attrs rather than `{}` placeholders. See * `doc/logging/usage/slog-parity.md` § "Message formatting". */ - void debug(String message, Attr[] extra = [], Exception? cause = Null) = + void debug(String message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Debug, message, extra, cause); /** * Emit a `Debug` record whose message is computed after the handler's enabled check. */ - void debug(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + void debug(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Debug, message, extra, cause); /** * Emit an `Info` record. */ - void info(String message, Attr[] extra = [], Exception? cause = Null) = + void info(String message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Info, message, extra, cause); /** * Emit an `Info` record whose message is computed after the handler's enabled check. */ - void info(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + void info(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Info, message, extra, cause); /** * Emit a `Warn` record. */ - void warn(String message, Attr[] extra = [], Exception? cause = Null) = + void warn(String message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Warn, message, extra, cause); /** * Emit a `Warn` record whose message is computed after the handler's enabled check. */ - void warn(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + void warn(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Warn, message, extra, cause); /** - * Emit an `Error` record. `cause` is an Ecstasy convenience; the Go slog idiom is - * usually `slog.Any("err", err)`. Either form can be rendered by a handler. + * Emit an `Error` record. `cause` is an Ecstasy convenience for a thrown exception. */ - void error(String message, Attr[] extra = [], Exception? cause = Null) = + void error(String message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Error, message, extra, cause); /** * Emit an `Error` record whose message is computed after the handler's enabled check. */ - void error(MessageSupplier message, Attr[] extra = [], Exception? cause = Null) = + void error(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = log(Level.Error, message, extra, cause); /** @@ -156,14 +145,14 @@ const Logger(Handler handler) * constructed and no attrs are merged. This mirrors Go slog's * `Handler.Enabled` fast path. */ - void log(Level level, String message, Attr[] extra = [], Exception? cause = Null) { + void log(Level level, String message, Attributes extra = Map:[], Exception? cause = Null) { emit(level, message, extra, cause, Null, -1); } /** * Open-level emission with lazy message construction. */ - void log(Level level, MessageSupplier message, Attr[] extra = [], Exception? cause = Null) { + void log(Level level, MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) { emit(level, message, extra, cause, Null, -1); } @@ -176,7 +165,7 @@ const Logger(Handler handler) * caller-site sugar into this method without changing the handler contract. */ void logAt(Level level, String message, String sourceFile, Int sourceLine, - Attr[] extra = [], Exception? cause = Null) { + Attributes extra = Map:[], Exception? cause = Null) { emit(level, message, extra, cause, sourceFile, sourceLine); } @@ -184,7 +173,7 @@ const Logger(Handler handler) * Open-level emission with explicit source metadata and lazy message construction. */ void logAt(Level level, MessageSupplier message, String sourceFile, Int sourceLine, - Attr[] extra = [], Exception? cause = Null) { + Attributes extra = Map:[], Exception? cause = Null) { emit(level, message, extra, cause, sourceFile, sourceLine); } @@ -192,7 +181,7 @@ const Logger(Handler handler) * Common emission path used by [log] and [logAt]. The handler owns any attributes * attached by derived loggers; the record receives only call-time extras. */ - private void emit(Level level, String message, Attr[] extra, Exception? cause, + private void emit(Level level, String message, Attributes extra, Exception? cause, String? sourceFile, Int sourceLine) { if (!handler.enabled(level)) { return; @@ -201,7 +190,7 @@ const Logger(Handler handler) time = clock.now, message = message, level = level, - attrs = Attr.resolveAll(extra), + attrs = extra, exception = cause, sourceFile = sourceFile, sourceLine = sourceLine, @@ -209,11 +198,10 @@ const Logger(Handler handler) } /** - * Lazy-message equivalent of [emit]. Both message and lazy attrs resolve only after - * the handler accepts the level, matching Go slog's `Enabled` fast path and - * `LogValuer`-style value resolution. + * Lazy-message equivalent of [emit]. The message is resolved only after the handler + * accepts the level, matching Go slog's `Enabled` fast path. */ - private void emit(Level level, MessageSupplier message, Attr[] extra, Exception? cause, + private void emit(Level level, MessageSupplier message, Attributes extra, Exception? cause, String? sourceFile, Int sourceLine) { if (!handler.enabled(level)) { return; @@ -222,7 +210,7 @@ const Logger(Handler handler) time = clock.now, message = message(), level = level, - attrs = Attr.resolveAll(extra), + attrs = extra, exception = cause, sourceFile = sourceFile, sourceLine = sourceLine, @@ -241,7 +229,7 @@ const Logger(Handler handler) * fiber-local state, code passes around a logger that visibly carries request or * component attrs. See `doc/logging/usage/slog-parity.md`. */ - Logger with(Attr[] more) { + Logger with(Attributes more) { if (more.empty) { return this; } diff --git a/lib_slogging/src/main/x/slogging/LoggerContext.x b/lib_slogging/src/main/x/slogging/LoggerContext.x index 41201a7d2c..a8b40eff48 100644 --- a/lib_slogging/src/main/x/slogging/LoggerContext.x +++ b/lib_slogging/src/main/x/slogging/LoggerContext.x @@ -9,7 +9,7 @@ import ecstasy.SharedContext; * models a logical execution context that flows across service calls and child fibers. * `LoggerContext` is the smallest explicit bridge: * - * Logger requestLog = logger.with([Attr.of("requestId", id)]); + * Logger requestLog = logger.with(Map:["requestId"=id]); * using (loggerContext.bind(requestLog)) { * worker.process^(); * } diff --git a/lib_slogging/src/main/x/slogging/MemoryHandler.x b/lib_slogging/src/main/x/slogging/MemoryHandler.x index 799d81f463..a461a7ae65 100644 --- a/lib_slogging/src/main/x/slogging/MemoryHandler.x +++ b/lib_slogging/src/main/x/slogging/MemoryHandler.x @@ -5,7 +5,7 @@ * * MemoryHandler h = new MemoryHandler(); * Logger logger = new Logger(h); - * logger.info("processed", [Attr.of("count", 42)]); + * logger.info("processed", Map:["count"=42]); * assert h.records.size == 1; * assert h.records[0].level == Level.Info; * @@ -25,7 +25,7 @@ service MemoryHandler * Mutable backing storage. Internal — Ecstasy forbids returning a mutable array * from a service call, so we expose [records] as an immutable snapshot below. */ - private Record[] recordList = new Record[]; + private Record[] recordList = new Array(); /** * Immutable snapshot of the captured records, in emission order. Each access @@ -58,8 +58,8 @@ service MemoryHandler * pre-bound attrs before the record reaches [handle]. */ @Override - Handler withAttrs(Attr[] attrs) { - return attrs.empty ? this : new BoundHandler(this, attrs); + Handler withAttrs(Attributes attrs) { + return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); } /** @@ -67,7 +67,7 @@ service MemoryHandler */ @Override Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(this, name); + return name == "" ? this : new BoundHandler(delegate=this, groupName=name); } /** diff --git a/lib_slogging/src/main/x/slogging/NopHandler.x b/lib_slogging/src/main/x/slogging/NopHandler.x index 44edbcade8..1251858d14 100644 --- a/lib_slogging/src/main/x/slogging/NopHandler.x +++ b/lib_slogging/src/main/x/slogging/NopHandler.x @@ -31,7 +31,7 @@ const NopHandler * Deriving a no-op handler is still no-op. */ @Override - Handler withAttrs(Attr[] attrs) = this; + Handler withAttrs(Attributes attrs) = this; /** * Grouping a no-op handler is still no-op. diff --git a/lib_slogging/src/main/x/slogging/Record.x b/lib_slogging/src/main/x/slogging/Record.x index da8eb1714c..e0052ec0d6 100644 --- a/lib_slogging/src/main/x/slogging/Record.x +++ b/lib_slogging/src/main/x/slogging/Record.x @@ -6,7 +6,7 @@ * no separate `marker` or `keyValues` — categorisation and structured fields all live * in `attrs`. * - * The `attrs` array carries the structured data visible to the current handler call. + * The `attrs` map carries the structured data visible to the current handler call. * Derived handlers may prepend attrs or wrap them in groups before forwarding to the * final backend. This matches Go slog's `Handler.WithAttrs` / `WithGroup` model. * @@ -22,7 +22,7 @@ const Record( // Open integer severity. Level level, // Structured data after any handler derivation has been applied. - Attr[] attrs, + Attributes attrs, // Ecstasy-specific convenience for a thrown exception/cause. Exception? exception = Null, // Optional source metadata. diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index bee84946f8..9ce6c7b343 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -14,18 +14,9 @@ * Console`. Threshold fixed at construction. No accumulation, no shared mutable state. * See `doc/logging/design/design.md` ("Sink type: `const` vs `service`") for the full rule. */ -const TextHandler(HandlerOptions options, String groupPrefix) +const TextHandler(HandlerOptions options = new HandlerOptions(), String groupName = "") implements Handler { - /** - * No-arg convenience: threshold `Info`, no group prefix. See parallel comment on - * `lib_logging.ConsoleLogSink` for why we use explicit delegating constructors - * instead of default-arg synthesis. - */ - construct() { - construct TextHandler(new HandlerOptions(), ""); - } - /** * Single-arg convenience: configurable threshold, no group prefix. */ @@ -66,7 +57,7 @@ const TextHandler(HandlerOptions options, String groupPrefix) .append("msg=") .append(quote(record.message)); - appendAttrs(buf, groupPrefix, record.attrs); + appendAttrs(buf, groupName, record.attrs); appendSource(buf, record); console.print(buf.toString()); @@ -78,27 +69,27 @@ const TextHandler(HandlerOptions options, String groupPrefix) } @Override - Handler withAttrs(Attr[] attrs) { - return attrs.empty ? this : new BoundHandler(this, attrs); + Handler withAttrs(Attributes attrs) { + return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); } @Override Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(this, name); + return name == "" ? this : new BoundHandler(delegate=this, groupName=name); } /** * Render one attr `key=value`, prefixing with the active group path if any. Nested - * groups (an `Attr` whose value is `Attr[]`) are flattened with a dot separator — - * matches `slog.TextHandler`. + * groups (a value that is itself a `Map`) are flattened with a dot + * separator — matches `slog.TextHandler`. */ - private void renderAttr(StringBuffer buf, String prefix, Attr a) { - String key = prefix == "" ? a.key : $"{prefix}.{a.key}"; - if (a.value.is(Attr[])) { - appendNestedAttrs(buf, key, a.value.as(Attr[])); + private void renderAttr(StringBuffer buf, String prefix, String key, AnyValue value) { + String fullKey = prefix == "" ? key : $"{prefix}.{key}"; + if (value.is(Map)) { + appendNestedAttrs(buf, fullKey, value); } else { - buf.append(key).append('=') - .append(options.redacts(a.key) ? options.redaction : a.value.toString()); + buf.append(fullKey).append('=') + .append(options.redacts(key) ? options.redaction : value.toString()); } } @@ -106,10 +97,10 @@ const TextHandler(HandlerOptions options, String groupPrefix) * Append top-level attrs. Each attr starts with a leading space because the base * record fields have already been rendered. */ - private void appendAttrs(StringBuffer buf, String prefix, Attr[] attrs) { - for (Attr attr : attrs) { + private void appendAttrs(StringBuffer buf, String prefix, Attributes attrs) { + for ((String key, AnyValue value) : attrs) { buf.append(' '); - renderAttr(buf, prefix, attr); + renderAttr(buf, prefix, key, value); } } @@ -117,14 +108,14 @@ const TextHandler(HandlerOptions options, String groupPrefix) * Append children of an attr group. The first child continues in the current * position; later children are separated with spaces. */ - private void appendNestedAttrs(StringBuffer buf, String prefix, Attr[] attrs) { + private void appendNestedAttrs(StringBuffer buf, String prefix, Map attrs) { Boolean first = True; - for (Attr attr : attrs) { + for ((String key, AnyValue value) : attrs) { if (!first) { buf.append(' '); } first = False; - renderAttr(buf, prefix, attr); + renderAttr(buf, prefix, key, value); } } diff --git a/lib_slogging/src/test/x/SLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest.x index 457eae8c60..2f446cbf6e 100644 --- a/lib_slogging/src/test/x/SLoggingTest.x +++ b/lib_slogging/src/test/x/SLoggingTest.x @@ -15,7 +15,7 @@ * - `LoggerContext` propagates request-scoped loggers; * - `JSONHandler` renders parseable `lib_json` documents; * - `HandlerContract` checks `withAttrs` / `withGroup` conformance; - * - `Attr.group(name, [...])` renders nested structure; + * - nested attribute maps render as nested structure; * - custom `Level` values comparable to / between the canonical four work. */ module SLoggingTest { diff --git a/lib_slogging/src/test/x/SLoggingTest/AttrTest.x b/lib_slogging/src/test/x/SLoggingTest/AttrTest.x deleted file mode 100644 index db99403144..0000000000 --- a/lib_slogging/src/test/x/SLoggingTest/AttrTest.x +++ /dev/null @@ -1,42 +0,0 @@ -import slogging.Attr; - -/** - * Tests for `Attr` — the single carrier for structured data. - */ -class AttrTest { - - @Test - void shouldCarryStringValue() { - Attr a = Attr.of("user", "alice"); - assert a.key == "user"; - assert a.value == "alice"; - } - - @Test - void shouldCarryIntegerValue() { - Attr a = Attr.of("count", 42); - assert a.key == "count"; - assert a.value == 42; - } - - @Test - void shouldCarryBooleanValue() { - Attr a = Attr.of("audit", True); - assert a.key == "audit"; - assert a.value == True; - } - - @Test - void shouldCarryNestedGroup() { - Attr group = Attr.group("user", [ - Attr.of("id", "u_1"), - Attr.of("role", "admin"), - ]); - assert group.key == "user"; - assert group.value.is(Attr[]); - Attr[] children = group.value.as(Attr[]); - assert children.size == 2; - assert children[0].key == "id" && children[0].value == "u_1"; - assert children[1].key == "role" && children[1].value == "admin"; - } -} diff --git a/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x b/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x index 4993c8726f..3cbe78ba42 100644 --- a/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x @@ -1,5 +1,5 @@ +import slogging.Attributes; import slogging.Handler; -import slogging.Attr; import slogging.Level; import slogging.Record; @@ -27,12 +27,12 @@ service CountingHandler } @Override - Handler withAttrs(Attr[] attrs) { - return attrs.empty ? this : new slogging.BoundHandler(this, attrs); + Handler withAttrs(Attributes attrs) { + return attrs.empty ? this : new slogging.BoundHandler(delegate=this, attrs=attrs); } @Override Handler withGroup(String name) { - return name == "" ? this : new slogging.BoundHandler(this, name); + return name == "" ? this : new slogging.BoundHandler(delegate=this, groupName=name); } } diff --git a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x index 509df3b9d7..6bca5256b2 100644 --- a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x @@ -1,4 +1,3 @@ -import slogging.Attr; import slogging.Level; import slogging.Logger; @@ -12,13 +11,13 @@ class EmissionTest { ListHandler handler = new ListHandler(); Logger logger = new Logger(handler); - logger.info("hello", [Attr.of("count", 1)]); + logger.info("hello", Map:["count"=1]); assert handler.records.size == 1; assert handler.records[0].level == Level.Info; assert handler.records[0].message == "hello"; assert handler.records[0].attrs.size == 1; - assert handler.records[0].attrs[0].key == "count"; + assert handler.records[0].attrs.contains("count"); } @Test @@ -44,7 +43,7 @@ class EmissionTest { Logger logger = new Logger(handler); Exception boom = new Exception("boom"); - logger.error("failed", [Attr.of("status", 500)], cause=boom); + logger.error("failed", Map:["status"=500], cause=boom); assert handler.records.size == 1; assert handler.records[0].exception?.text == "boom" : assert; diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x b/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x index 6da6a5ede9..d4116f04ff 100644 --- a/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x @@ -1,4 +1,5 @@ -import slogging.Attr; +import slogging.AnyValue; +import slogging.Attributes; import slogging.Handler; import slogging.Level; import slogging.Record; @@ -16,15 +17,17 @@ class HandlerContract { * Verify that `withAttrs` prepends bound attributes to call-time attributes. */ static void assertWithAttrsPrepend(Handler root, function Record[] () records) { - Handler derived = root.withAttrs([Attr.of("requestId", "r_1")]); - derived.handle(sample([Attr.of("path", "/checkout")])); + Handler derived = root.withAttrs(Map:["requestId"="r_1"]); + derived.handle(sample(Map:["path"="/checkout"])); Record[] captured = records(); assert captured.size == 1; - assert captured[0].attrs.size == 2; - assert captured[0].attrs[0].key == "requestId"; - assert captured[0].attrs[0].value == "r_1"; - assert captured[0].attrs[1].key == "path"; + Attributes attrs = captured[0].attrs; + assert attrs.size == 2; + String[] keys = attrs.keys.toArray(); + assert keys[0] == "requestId"; + assert attrs["requestId"] == "r_1"; + assert keys[1] == "path"; } /** @@ -32,24 +35,24 @@ class HandlerContract { */ static void assertWithGroupNests(Handler root, function Record[] () records) { Handler grouped = root.withGroup("payments"); - grouped.handle(sample([Attr.of("amount", 1099)])); + grouped.handle(sample(Map:["amount"=1099])); Record[] captured = records(); assert captured.size == 1; - assert captured[0].attrs.size == 1; - assert captured[0].attrs[0].key == "payments"; - assert captured[0].attrs[0].value.is(Attr[]); + Attributes attrs = captured[0].attrs; + assert attrs.size == 1; + AnyValue paymentsValue = attrs["payments"] ?: assert; + assert paymentsValue.is(Map); - Attr[] children = captured[0].attrs[0].value.as(Attr[]); + Map children = paymentsValue.as(Map); assert children.size == 1; - assert children[0].key == "amount"; - assert children[0].value == 1099; + assert children["amount"] == 1099; } /** * Shared sample record. */ - private static Record sample(Attr[] attrs) { + private static Record sample(Attributes attrs) { return new Record( time = new Time("2019-05-22T120123.456Z"), message = "event", diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x b/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x index b5b4cb1d5a..b07c93c6d9 100644 --- a/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x @@ -1,4 +1,3 @@ -import slogging.Attr; import slogging.Logger; /** @@ -13,13 +12,12 @@ class HandlerDerivationTest { TrackingHandler handler = new TrackingHandler(); Logger base = new Logger(handler); - Logger derived = base.with([Attr.of("requestId", "r_1")]); + Logger derived = base.with(Map:["requestId"="r_1"]); derived.info("processing"); assert handler.withAttrsCalls == 1; assert handler.lastAttrs.size == 1; - assert handler.lastAttrs[0].key == "requestId"; - assert handler.lastAttrs[0].value == "r_1"; + assert handler.lastAttrs["requestId"] == "r_1"; assert handler.records.size == 1; } @@ -29,7 +27,7 @@ class HandlerDerivationTest { Logger base = new Logger(handler); Logger grouped = base.withGroup("payments"); - grouped.info("charged", [Attr.of("amount", 1099)]); + grouped.info("charged", Map:["amount"=1099]); assert handler.withGroupCalls == 1; assert handler.groups.size == 1; diff --git a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x index 69bff1f730..c4926fe6e2 100644 --- a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x @@ -2,7 +2,7 @@ import json.Doc; import json.JsonObject; import json.Parser; -import slogging.Attr; +import slogging.AnyValue; import slogging.HandlerOptions; import slogging.JSONHandler; import slogging.Level; @@ -18,20 +18,21 @@ class JSONHandlerTest { void shouldRenderParseableJsonWithEscapingAndNestedGroups() { JSONHandler handler = new JSONHandler(); Exception boom = new Exception("bad \"card\""); + Map userAttrs = Map:["id"="u_3"]; Record record = new Record( time = new Time("2019-05-22T120123.456Z"), message = "charged \"ok\"", level = Level.Info, - attrs = [ - Attr.of("amount", 1099), - Attr.group("user", [Attr.of("id", "u_3")]), + attrs = Map:[ + "amount" = 1099, + "user" = userAttrs, ], exception = boom, sourceFile = "PaymentService.x", sourceLine = 42, ); - JsonObject obj = parseObject(handler.render(record)); + JsonObject obj = handler.toJson(record); assert obj["level"] == "INFO"; assert obj["msg"] == "charged \"ok\""; @@ -54,14 +55,15 @@ class JSONHandlerTest { @Test void shouldRenderGroupedDerivedLoggerAsNestedJson() { JSONHandler handler = new JSONHandler(); + Map paymentAttrs = Map:["amount"=1099]; Record record = new Record( time = new Time("2019-05-22T120123.456Z"), message = "charged", level = Level.Info, - attrs = [Attr.group("payments", [Attr.of("amount", 1099)])], + attrs = Map:["payments"=paymentAttrs], ); - JsonObject obj = parseObject(handler.render(record)); + JsonObject obj = handler.toJson(record); assert obj["payments"].is(JsonObject); JsonObject payments = obj["payments"].as(JsonObject); @@ -76,10 +78,10 @@ class JSONHandlerTest { time = new Time("2019-05-22T120123.456Z"), message = "auth", level = Level.Info, - attrs = [Attr.of("token", "secret"), Attr.of("user", "u_1")], + attrs = Map:["token"="secret", "user"="u_1"], ); - JsonObject obj = parseObject(handler.render(record)); + JsonObject obj = handler.toJson(record); assert obj["token"] == "***"; assert obj["user"] == "u_1"; diff --git a/lib_slogging/src/test/x/SLoggingTest/LazyCounter.x b/lib_slogging/src/test/x/SLoggingTest/LazyCounter.x deleted file mode 100644 index 29a7129216..0000000000 --- a/lib_slogging/src/test/x/SLoggingTest/LazyCounter.x +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Service-backed counter for `Attr.lazy` tests. `Attr` is a `const`, so the supplier - * carrier is frozen; a service reference is passable while a mutable local counter is not. - */ -service LazyCounter { - public/private Int calls = 0; - - String value(String result) { - ++calls; - return result; - } -} diff --git a/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x index d407740365..4890c2cbf1 100644 --- a/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x @@ -1,9 +1,8 @@ -import slogging.Attr; import slogging.Logger; /** * Verifies slog-shaped lazy logging semantics. The logger must perform the handler - * enabled check before it invokes lazy message suppliers or `Attr.lazy` suppliers. + * enabled check before it invokes lazy message suppliers. */ class LazyLoggingTest { @@ -38,41 +37,4 @@ class LazyLoggingTest { assert handler.records.size == 1; assert handler.records[0].message == "expensive info message"; } - - @Test - void shouldNotEvaluateLazyAttrWhenLevelDisabled() { - ListHandler handler = new ListHandler(); - Logger logger = new Logger(handler); - @Volatile Int calls = 0; - - handler.setLevel(slogging.Level.Info); - logger.debug("payload", [ - Attr.lazy("json", () -> { - ++calls; - return "expensive"; - }), - ]); - - assert calls == 0; - assert handler.records.empty; - } - - @Test - void shouldEvaluateLazyAttrWhenEnabled() { - ListHandler handler = new ListHandler(); - Logger logger = new Logger(handler); - LazyCounter counter = new LazyCounter(); - - logger.info("payload", [ - Attr.lazy("json", () -> { - return counter.value("expensive"); - }), - ]); - - assert counter.calls == 1; - assert handler.records.size == 1; - assert handler.records[0].attrs.size == 1; - assert handler.records[0].attrs[0].key == "json"; - assert handler.records[0].attrs[0].value.as(String) == "expensive"; - } } diff --git a/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x b/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x index e39b0f2934..be4ab199ec 100644 --- a/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/LevelCheckTest.x @@ -1,4 +1,3 @@ -import slogging.Attr; import slogging.Level; import slogging.Logger; diff --git a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x index b50cdccc3e..6a2f50a211 100644 --- a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x @@ -1,4 +1,4 @@ -import slogging.Attr; +import slogging.Attributes; import slogging.Handler; import slogging.Level; import slogging.Record; @@ -44,13 +44,13 @@ service ListHandler } @Override - Handler withAttrs(Attr[] attrs) { - return attrs.empty ? this : new slogging.BoundHandler(this, attrs); + Handler withAttrs(Attributes attrs) { + return attrs.empty ? this : new slogging.BoundHandler(delegate=this, attrs=attrs); } @Override Handler withGroup(String name) { - return name == "" ? this : new slogging.BoundHandler(this, name); + return name == "" ? this : new slogging.BoundHandler(delegate=this, groupName=name); } void setLevel(Level level) { diff --git a/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x b/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x index c2dd47f960..f9ab92eb62 100644 --- a/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x @@ -1,4 +1,3 @@ -import slogging.Attr; import slogging.Logger; import slogging.LoggerContext; @@ -11,7 +10,7 @@ class LoggerContextTest { void shouldExposeLoggerInsideUsingScope() { LoggerContext context = new LoggerContext(); ListHandler handler = new ListHandler(); - Logger logger = new Logger(handler).with([Attr.of("requestId", "r_1")]); + Logger logger = new Logger(handler).with(Map:["requestId"="r_1"]); assert !context.current(); @@ -21,8 +20,8 @@ class LoggerContextTest { } assert handler.records.size == 1; - assert handler.records[0].attrs[0].key == "requestId"; - assert handler.records[0].attrs[0].value == "r_1"; + assert handler.records[0].attrs.contains("requestId"); + assert handler.records[0].attrs["requestId"] == "r_1"; assert !context.current(); } diff --git a/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x index 41441c075b..82fbaaad06 100644 --- a/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x @@ -1,4 +1,3 @@ -import slogging.Attr; import slogging.Level; import slogging.Logger; import slogging.MemoryHandler; @@ -14,13 +13,13 @@ class MemoryHandlerTest { MemoryHandler handler = new MemoryHandler(); Logger logger = new Logger(handler); - logger.info("processed", [Attr.of("count", 42)]); + logger.info("processed", Map:["count"=42]); assert handler.records.size == 1; assert handler.records[0].level == Level.Info; assert handler.records[0].message == "processed"; assert handler.records[0].attrs.size == 1; - assert handler.records[0].attrs[0].key == "count"; + assert handler.records[0].attrs.contains("count"); } @Test diff --git a/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x b/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x index 425c24d9fc..9c0ad24824 100644 --- a/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x @@ -1,4 +1,3 @@ -import slogging.Attr; import slogging.Level; import slogging.Logger; @@ -13,12 +12,12 @@ class SourceLocationTest { Logger logger = new Logger(handler); logger.logAt(Level.Warn, "slow payment", "PaymentService.x", 77, - [Attr.of("elapsedMs", 451)]); + Map:["elapsedMs"=451]); assert handler.records.size == 1; assert handler.records[0].sourceFile == "PaymentService.x"; assert handler.records[0].sourceLine == 77; - assert handler.records[0].attrs[0].key == "elapsedMs"; + assert handler.records[0].attrs.contains("elapsedMs"); } @Test diff --git a/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x b/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x index da5085e802..00e7ddc8ac 100644 --- a/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x @@ -1,4 +1,4 @@ -import slogging.Attr; +import slogging.Attributes; import slogging.Handler; import slogging.Level; import slogging.Record; @@ -11,10 +11,10 @@ import slogging.Record; service TrackingHandler implements Handler { - public/private Int withAttrsCalls = 0; - public/private Int withGroupCalls = 0; - public/private Attr[] lastAttrs = []; - public/private String[] groups = []; + public/private Int withAttrsCalls = 0; + public/private Int withGroupCalls = 0; + public/private Attributes lastAttrs = Map:[]; + public/private String[] groups = []; private Record[] recordList = new Record[]; @RO Record[] records.get() { @@ -32,9 +32,9 @@ service TrackingHandler } @Override - Handler withAttrs(Attr[] attrs) { + Handler withAttrs(Attributes attrs) { ++withAttrsCalls; - lastAttrs = attrs.toArray(Constant); + lastAttrs = attrs.makeImmutable(); return this; } diff --git a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x index 2664ff125b..0feefcebd2 100644 --- a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x @@ -1,4 +1,5 @@ -import slogging.Attr; +import slogging.AnyValue; +import slogging.Attributes; import slogging.Logger; /** @@ -24,42 +25,44 @@ class WithGroupTest { @Test void shouldGroupOnlySubsequentAttrs() { ListHandler handler = new ListHandler(); - Logger base = new Logger(handler).with([Attr.of("env", "prod")]); + Logger base = new Logger(handler).with(Map:["env"="prod"]); Logger grouped = base.withGroup("payments"); - grouped.info("charged", [Attr.of("amount", 1099)]); + grouped.info("charged", Map:["amount"=1099]); // Matches Go slog ordering: attrs bound before WithGroup stay outside the group; // attrs supplied after WithGroup are nested under the group. assert handler.records.size == 1; - Attr[] attrs = handler.records[0].attrs; + Attributes attrs = handler.records[0].attrs; assert attrs.size == 2; - assert attrs[0].key == "env"; - assert attrs[1].key == "payments"; - assert attrs[1].value.is(Attr[]); + String[] keys = attrs.keys.toArray(); + assert keys[0] == "env"; + assert keys[1] == "payments"; + AnyValue paymentsValue = attrs["payments"] ?: assert; + assert paymentsValue.is(Map); - Attr[] paymentAttrs = attrs[1].value.as(Attr[]); + Map paymentAttrs = paymentsValue.as(Map); assert paymentAttrs.size == 1; - assert paymentAttrs[0].key == "amount"; - assert paymentAttrs[0].value == 1099; + assert paymentAttrs["amount"] == 1099; } @Test void shouldPutAttrsBoundAfterGroupInsideGroup() { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); - Logger grouped = base.withGroup("payments").with([Attr.of("currency", "SEK")]); + Logger grouped = base.withGroup("payments").with(Map:["currency"="SEK"]); - grouped.info("charged", [Attr.of("amount", 1099)]); + grouped.info("charged", Map:["amount"=1099]); - Attr[] attrs = handler.records[0].attrs; + Attributes attrs = handler.records[0].attrs; assert attrs.size == 1; - assert attrs[0].key == "payments"; - assert attrs[0].value.is(Attr[]); + AnyValue paymentsValue = attrs["payments"] ?: assert; + assert paymentsValue.is(Map); - Attr[] paymentAttrs = attrs[0].value.as(Attr[]); + Map paymentAttrs = paymentsValue.as(Map); assert paymentAttrs.size == 2; - assert paymentAttrs[0].key == "currency"; - assert paymentAttrs[1].key == "amount"; + String[] paymentKeys = paymentAttrs.keys.toArray(); + assert paymentKeys[0] == "currency"; + assert paymentKeys[1] == "amount"; } } diff --git a/lib_slogging/src/test/x/SLoggingTest/WithTest.x b/lib_slogging/src/test/x/SLoggingTest/WithTest.x index 2c19a0669c..328e82c78b 100644 --- a/lib_slogging/src/test/x/SLoggingTest/WithTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/WithTest.x @@ -1,4 +1,4 @@ -import slogging.Attr; +import slogging.Attributes; import slogging.Logger; /** @@ -16,18 +16,19 @@ class WithTest { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); - Logger derived = base.with([ - Attr.of("requestId", "r_42"), - Attr.of("user", "u_3"), + Logger derived = base.with(Map:[ + "requestId" = "r_42", + "user" = "u_3", ]); derived.info("processing"); assert handler.records.size == 1; - Attr[] attrs = handler.records[0].attrs; + Attributes attrs = handler.records[0].attrs; assert attrs.size == 2; - assert attrs[0].key == "requestId" && attrs[0].value == "r_42"; - assert attrs[1].key == "user" && attrs[1].value == "u_3"; + String[] keys = attrs.keys.toArray(); + assert keys[0] == "requestId" && attrs["requestId"] == "r_42"; + assert keys[1] == "user" && attrs["user"] == "u_3"; } @Test @@ -35,16 +36,17 @@ class WithTest { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); - Logger withA = base.with([Attr.of("a", 1)]); - Logger withB = withA.with([Attr.of("b", 2)]); + Logger withA = base.with(Map:["a"=1]); + Logger withB = withA.with(Map:["b"=2]); withB.info("event"); assert handler.records.size == 1; - Attr[] attrs = handler.records[0].attrs; + Attributes attrs = handler.records[0].attrs; assert attrs.size == 2; - assert attrs[0].key == "a"; - assert attrs[1].key == "b"; + String[] keys = attrs.keys.toArray(); + assert keys[0] == "a"; + assert keys[1] == "b"; } @Test @@ -52,14 +54,15 @@ class WithTest { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); - Logger reqLog = base.with([Attr.of("requestId", "r_99")]); + Logger reqLog = base.with(Map:["requestId"="r_99"]); - reqLog.info("processing", [Attr.of("path", "/api")]); + reqLog.info("processing", Map:["path"="/api"]); - Attr[] attrs = handler.records[0].attrs; + Attributes attrs = handler.records[0].attrs; assert attrs.size == 2; - assert attrs[0].key == "requestId"; - assert attrs[1].key == "path"; + String[] keys = attrs.keys.toArray(); + assert keys[0] == "requestId"; + assert keys[1] == "path"; } @Test @@ -67,7 +70,7 @@ class WithTest { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); - Logger derived = base.with([Attr.of("k", "v")]); + Logger derived = base.with(Map:["k"="v"]); base.info("from base"); derived.info("from derived"); From 9944c4d48ac3d79823869ae5c7f9fcc169b76119 Mon Sep 17 00:00:00 2001 From: Jonathan Knight Date: Thu, 7 May 2026 16:01:00 +0300 Subject: [PATCH 25/28] Fix `TestSLogging.x` in manualTests --- .../src/main/x/slogging/JSONHandler.x | 16 ++--- .../src/main/x/slogging/TextHandler.x | 39 ++++++++--- manualTests/src/main/x/TestSLogging.x | 67 +++++++------------ 3 files changed, 63 insertions(+), 59 deletions(-) diff --git a/lib_slogging/src/main/x/slogging/JSONHandler.x b/lib_slogging/src/main/x/slogging/JSONHandler.x index ab3c2f2504..8f1b80fc84 100644 --- a/lib_slogging/src/main/x/slogging/JSONHandler.x +++ b/lib_slogging/src/main/x/slogging/JSONHandler.x @@ -19,22 +19,22 @@ const JSONHandler * * @param options (optional) the handler's options * @param groupName (optional) the handler's group name - * @param handler (optional) the handler that will process the [JsonObject] produced from the - * log [Record] (the default will print the json to the console) + * @param handler (optional) the consumer that will process the [JsonObject] produced from + * the log [Record] (the default will print the json to the console) */ - construct (HandlerOptions? options = Null, String groupName = "", JsonHandler? handler = Null) { + construct (HandlerOptions? options = Null, String groupName = "", JsonConsumer? consumer = Null) { this.options = options ?: new HandlerOptions(); this.groupName = groupName; - this.handler = handler ?: defaultHandler; + this.consumer = consumer ?: defaultConsumer; } - typedef function void (JsonObject) as JsonHandler; + typedef function void (JsonObject) as JsonConsumer; HandlerOptions options; String groupName; - JsonHandler handler; + JsonConsumer consumer; /** * Cheap threshold check; no JSON work happens for disabled records. @@ -49,10 +49,10 @@ const JSONHandler */ @Override void handle(Record record) { - handler(toJson(record)); + consumer(toJson(record)); } - private static void defaultHandler(JsonObject obj) { + private static void defaultConsumer(JsonObject obj) { @Inject Console console; console.print(Printer.DEFAULT.render(obj)); } diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index 9ce6c7b343..a56242ba18 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -14,24 +14,38 @@ * Console`. Threshold fixed at construction. No accumulation, no shared mutable state. * See `doc/logging/design/design.md` ("Sink type: `const` vs `service`") for the full rule. */ -const TextHandler(HandlerOptions options = new HandlerOptions(), String groupName = "") +const TextHandler implements Handler { /** - * Single-arg convenience: configurable threshold, no group prefix. + * Create a [JSONHandler]. + * + * @param options (optional) the handler's options + * @param groupName (optional) the handler's group name + * @param handler (optional) the consumer that will process the String produced from the log + * [Record] (the default will print the json to the console) */ - construct(Level rootLevel) { - construct TextHandler(new HandlerOptions(rootLevel), ""); + construct (HandlerOptions? options = Null, String groupName = "", TextConsumer? consumer = Null) + { + this.options = options ?: new HandlerOptions(); + this.groupName = groupName; + this.consumer = consumer ?: defaultConsumer; } + typedef function void (String) as TextConsumer; + /** - * Production-options convenience. + * Single-arg convenience: configurable threshold, no group prefix. */ - construct(HandlerOptions options) { - construct TextHandler(options, ""); + construct(Level rootLevel) { + construct TextHandler(new HandlerOptions(rootLevel)); } - @Inject Console console; + HandlerOptions options; + + String groupName; + + TextConsumer consumer; /** * Cheap threshold check. This is intentionally only arithmetic on the level's @@ -60,11 +74,11 @@ const TextHandler(HandlerOptions options = new HandlerOptions(), String groupNam appendAttrs(buf, groupName, record.attrs); appendSource(buf, record); - console.print(buf.toString()); + consumer(buf.toString()); if (Exception e ?= record.exception) { // TODO(impl): pretty-print stack frames; for now rely on Exception.toString(). - console.print(e.toString()); + consumer(e.toString()); } } @@ -78,6 +92,11 @@ const TextHandler(HandlerOptions options = new HandlerOptions(), String groupNam return name == "" ? this : new BoundHandler(delegate=this, groupName=name); } + private static void defaultConsumer(String text) { + @Inject Console console; + console.print(text); + } + /** * Render one attr `key=value`, prefixing with the active group path if any. Nested * groups (a value that is itself a `Map`) are flattened with a dot diff --git a/manualTests/src/main/x/TestSLogging.x b/manualTests/src/main/x/TestSLogging.x index fc3a4c1fce..bf13fc0b01 100644 --- a/manualTests/src/main/x/TestSLogging.x +++ b/manualTests/src/main/x/TestSLogging.x @@ -9,8 +9,8 @@ module TestSLogging { package slog import slogging.xtclang.org; + import slog.Attributes; import slog.AsyncHandler; - import slog.Attr; import slog.HandlerOptions; import slog.JSONHandler; import slog.Level; @@ -26,7 +26,7 @@ module TestSLogging { void run() { console.print("=== TestSLogging: slog-shaped logging ==="); runInjectedLogger(); - runDerivedLoggersAndLazyAttrs(); + runDerivedLoggersAttrs(); runHandlerPrimitives(); runContextBinding(); } @@ -42,7 +42,7 @@ module TestSLogging { assert logger.infoEnabled; assert !logger.debugEnabled; - logger.info("hello", [Attr.of("style", "slog")]); + logger.info("hello", ["style"="slog"]); @Volatile Int lazyCalls = 0; logger.debug(() -> { @@ -54,59 +54,47 @@ module TestSLogging { logger.info(() -> { ++lazyCalls; return "lazy info message built after the Info check"; - }, [Attr.of("scenario", "injected")]); + }, ["scenario"="injected"]); assert lazyCalls == 1; try { failingOperation(); } catch (Exception e) { - logger.error("operation failed", [Attr.of("operation", "manual")], cause=e); + logger.error("operation failed", ["operation"="manual"], cause=e); } Level notice = new Level(2, "NOTICE"); - logger.log(notice, "custom level", [Attr.of("severity", notice.severity)]); + logger.log(notice, "custom level", ["severity"=notice.severity]); logger.logAt(Level.Warn, () -> "source-aware lazy message", - "TestSLogging.x", 64, [Attr.of("source", "explicit")]); + "TestSLogging.x", 64, ["source"="explicit"]); } /** * The central slog idea: derive loggers with attrs/groups instead of named logger - * categories and MDC. Also exercises `Attr.lazy`, the POC equivalent of Go - * `LogValuer`. + * categories and MDC. */ - void runDerivedLoggersAndLazyAttrs() { - console.print("--- derived loggers, groups, and lazy attrs ---"); + void runDerivedLoggersAttrs() { + console.print("--- derived loggers and groups ---"); MemoryHandler capture = new MemoryHandler(); Logger root = new Logger(capture); - LazyCounter counter = new LazyCounter(); - Logger payments = root.with([ - Attr.of ("requestId", "r_slog"), - Attr.lazy("bound", () -> counter.value("bound-value")), - ]).withGroup("payments"); + Logger payments = root.with(["requestId"="r_slog"]).withGroup("payments"); - payments.info("charged", [ - Attr.lazy ("payload", () -> counter.value("payload-value")), - Attr.group("card", [Attr.of("last4", "4242")]), - Attr.of ("amount", 1099), - ]); + Attributes attrs = Map:["card"=["last4"="4242"], "amount"=1099]; + payments.info("charged", attrs); - assert counter.calls == 2; assert capture.records.size == 1; Record record = capture.records[0]; assert record.message == "charged"; - assert record.attrs[0].key == "requestId"; - assert record.attrs[1].key == "bound"; - assert record.attrs[1].value.as(String) == "bound-value"; - assert record.attrs[2].key == "payments"; - - Attr[] paymentAttrs = record.attrs[2].value.as(Attr[]); - assert paymentAttrs[0].key == "payload"; - assert paymentAttrs[0].value.as(String) == "payload-value"; - assert paymentAttrs[1].key == "card"; - assert paymentAttrs[2].key == "amount"; + assert record.attrs["requestId"] == "r_slog"; + assert var paymentAttrs := record.attrs.get("payments"); + assert paymentAttrs.is(Attributes); + assert paymentAttrs["amount"] == 1099; + assert var cardAttrs := paymentAttrs.get("card"); + assert cardAttrs.is(Attributes); + assert cardAttrs["last4"] == "4242"; } /** @@ -119,19 +107,16 @@ module TestSLogging { MemoryHandler asyncTarget = new MemoryHandler(); AsyncHandler async = new AsyncHandler(asyncTarget, 8); Logger asyncLogger = new Logger(async); - asyncLogger.info("async record", [Attr.of("queued", True)]); + asyncLogger.info("async record", ["queued"=True]); async.flush(); assert asyncTarget.records.size == 1; Logger textLogger = new Logger(new TextHandler(Level.Debug)); - textLogger.debug("text handler debug", [Attr.of("visible", True)]); + textLogger.debug("text handler debug", ["visible"=True]); Logger jsonLogger = new Logger(new JSONHandler( new HandlerOptions(Level.Debug, ["secret"]))); - jsonLogger.info("json handler redaction", [ - Attr.of("secret", "redact-me"), - Attr.of("visible", "ok"), - ]); + jsonLogger.info("json handler redaction", ["secret"="redact-me", "visible"="ok"]); Logger noop = new Logger(new NopHandler()); assert !noop.errorEnabled; @@ -147,7 +132,7 @@ module TestSLogging { MemoryHandler capture = new MemoryHandler(); Logger root = new Logger(capture); LoggerContext context = new LoggerContext(); - Logger bound = root.with([Attr.of("context", "bound")]); + Logger bound = root.with(["context"="bound"]); using (context.bind(bound)) { Logger current = context.currentOr(root); @@ -155,8 +140,8 @@ module TestSLogging { } assert capture.records.size == 1; - assert capture.records[0].attrs[0].key == "context"; - assert capture.records[0].attrs[0].value.as(String) == "bound"; + assert var value := capture.records[0].attrs.get("context"); + assert value.is(String) && value == "bound"; } void failingOperation() { From f66fed5836a92b1cb2981e4748a2d3048471f229 Mon Sep 17 00:00:00 2001 From: Jonathan Knight Date: Fri, 8 May 2026 10:31:07 +0300 Subject: [PATCH 26/28] Align log levels with Open Telemetry severity values --- lib_slogging/src/main/x/slogging.x | 7 +- lib_slogging/src/main/x/slogging/Level.x | 76 ++++++++++++------- .../src/test/x/SLoggingTest/EmissionTest.x | 2 +- .../src/test/x/SLoggingTest/LevelTest.x | 4 +- 4 files changed, 53 insertions(+), 36 deletions(-) diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index 0eb188bbde..0d9f3fff2d 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -90,13 +90,8 @@ module slogging.xtclang.org { * Using array and map values may carry a higher performance overhead compared to primitive * values. */ - typedef PrimitiveValue | AnyValue[] | Map as AnyValue; - - /** - * The various primitive values allowed in an AnyValue. - */ typedef Nullable | Boolean | String | Int | IntLiteral | Float | FPLiteral | Byte[] - as PrimitiveValue; + | AnyValue[] | Map as AnyValue; /** * A map of attribute key-value pairs carried by resources, scopes, and data points. diff --git a/lib_slogging/src/main/x/slogging/Level.x b/lib_slogging/src/main/x/slogging/Level.x index dac0cc7b0c..1e16951950 100644 --- a/lib_slogging/src/main/x/slogging/Level.x +++ b/lib_slogging/src/main/x/slogging/Level.x @@ -1,34 +1,49 @@ /** - * Corresponds to `log/slog.Level` (`go.dev/src/log/slog/level.go`). Severity is a plain - * integer; well-known constants are named (`DEBUG=-4`, `INFO=0`, `WARN=4`, `ERROR=8`) - * but callers may construct intermediate or beyond-canonical levels. + * Corresponds to [Open Telemetry Logging data model Severity fields] + * (https://opentelemetry.io/docs/specs/otel/logs/data-model/#severity-fields) * - * The `slog`-style level model: an open integer line, in contrast to the SLF4J-shaped - * sibling library's closed `Trace / Debug / Info / Warn / Error / Off` enum. The - * canonical four levels are spaced four apart so callers can interject custom levels - * (`Notice` between `Info` and `Warn`, `Critical` past `Error`) without colliding with - * a library's choices. + * Severity is a plain integer; well-known constants are named (`DEBUG=5`, `INFO=9`, `WARN=13`, + * `ERROR=17`) but callers may construct intermediate or beyond-canonical levels. Smaller numerical + * values correspond to less severe events (such as debug events), larger numerical values + * correspond to more severe events (such as errors and critical events). * - * Level NOTICE = new Level(2, "NOTICE"); + * The level model is an open integer line. The canonical four levels are spaced four apart so + * callers can interject custom levels (for example, adding `Notice` between `Info` and `Warn`, + * `Critical` past `Error`) without colliding with a library's choices. + * + * Level NOTICE = new Level(10, "NOTICE"); + * Level CRITICAL = new Level(18, "CRITICAL"); * logger.log(NOTICE, "user signed in"); * - * For symmetry with the SLF4J library the four canonical names are exposed as `static` - * constants on this type. Comparisons go through `severity` directly. + * The four canonical names are exposed as `static` constants on this type. Comparisons go through + * `severity` directly. * * if (level.severity >= threshold.severity) { ... } + * + * @param severity numerical value of the severity + * @param label human-readable label for the severity */ const Level(Int severity, String label) - implements Orderable { + implements Orderable, Hashable { + /** + * A debugging level log event. + */ + static Level Debug = new Level( 5, "DEBUG"); + + /** + * An informational level log event. + */ + static Level Info = new Level( 9, "INFO"); + + /** + * A warning level log event. + */ + static Level Warn = new Level(13, "WARN"); /** - * Canonical levels. Spacing matches `log/slog`'s `LevelDebug=-4`, `LevelInfo=0`, - * `LevelWarn=4`, `LevelError=8` — four apart so user-defined levels (e.g. `NOTICE` - * between `Info` and `Warn`) can be interjected without re-spacing. + * An error level log event. */ - static Level Debug = new Level(-4, "DEBUG"); - static Level Info = new Level( 0, "INFO"); - static Level Warn = new Level( 4, "WARN"); - static Level Error = new Level( 8, "ERROR"); + static Level Error = new Level(17, "ERROR"); /** * `True` iff a record at this level should be emitted given the supplied @@ -40,16 +55,23 @@ const Level(Int severity, String label) } /** - * Natural ordering is severity ordering, matching Go slog's integer-level model. + * Natural ordering is severity ordering. */ @Override - static Ordered compare(CompileType a, CompileType b) { - return a.severity <=> b.severity; - } + static Ordered compare(CompileType a, CompileType b) + = a.severity <=> b.severity; + + @Override + static Boolean equals(CompileType value1, CompileType value2) + = value1.severity == value2.severity; + + @Override + static Int hashCode(CompileType value) + = value.severity.hashCode(); + + @Override + Int estimateStringLength() = label.size; - /** - * Human-readable level label used by text/JSON handlers. - */ @Override - String toString() = label; + Appender appendTo(Appender buf) = label.appendTo(buf); } diff --git a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x index 6bca5256b2..5224089351 100644 --- a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x @@ -53,7 +53,7 @@ class EmissionTest { void shouldRouteCustomLevelThroughLog() { ListHandler handler = new ListHandler(); Logger logger = new Logger(handler); - Level notice = new Level(2, "NOTICE"); + Level notice = new Level(10, "NOTICE"); logger.log(notice, "user signed in"); diff --git a/lib_slogging/src/test/x/SLoggingTest/LevelTest.x b/lib_slogging/src/test/x/SLoggingTest/LevelTest.x index 831426e79b..8fdc9e9a94 100644 --- a/lib_slogging/src/test/x/SLoggingTest/LevelTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/LevelTest.x @@ -18,8 +18,8 @@ class LevelTest { @Test void shouldSupportCustomLevels() { // The canonical levels are spaced four apart precisely so callers can interject. - Level notice = new Level(2, "NOTICE"); - Level critical = new Level(12, "CRITICAL"); + Level notice = new Level(10, "NOTICE"); + Level critical = new Level(18, "CRITICAL"); assert notice.severity > Level.Info.severity; assert notice.severity < Level.Warn.severity; From a6281e4137ae3a06a2eec0abbfba101b4db1bac5 Mon Sep 17 00:00:00 2001 From: Jonathan Knight Date: Fri, 8 May 2026 22:27:11 +0300 Subject: [PATCH 27/28] Align log `Record` closer to Open Telemetry, which allows `AnyValue` for the message field. Claude also reviewed and cleaned up quite a bit of code. --- lib_slogging/build.gradle.kts | 1 + lib_slogging/src/main/x/slogging.x | 27 +++--- .../src/main/x/slogging/AsyncHandler.x | 20 ++-- .../src/main/x/slogging/BoundHandler.x | 32 +++---- lib_slogging/src/main/x/slogging/Handler.x | 8 +- .../src/main/x/slogging/HandlerOptions.x | 13 +-- .../src/main/x/slogging/JSONHandler.x | 77 ++++++++------- lib_slogging/src/main/x/slogging/Level.x | 4 +- lib_slogging/src/main/x/slogging/Logger.x | 93 +++++++++---------- .../src/main/x/slogging/LoggerContext.x | 10 +- .../src/main/x/slogging/MemoryHandler.x | 30 ++---- lib_slogging/src/main/x/slogging/NopHandler.x | 2 +- lib_slogging/src/main/x/slogging/Record.x | 46 ++++----- .../src/main/x/slogging/TextHandler.x | 52 ++++------- lib_slogging/src/test/x/SLoggingTest.x | 2 +- .../src/test/x/SLoggingTest/CountingHandler.x | 15 ++- .../src/test/x/SLoggingTest/EmissionTest.x | 4 +- .../src/test/x/SLoggingTest/HandlerContract.x | 34 +++---- .../test/x/SLoggingTest/HandlerContractTest.x | 2 +- .../x/SLoggingTest/HandlerDerivationTest.x | 8 +- .../src/test/x/SLoggingTest/JSONHandlerTest.x | 26 +++--- .../src/test/x/SLoggingTest/LazyLoggingTest.x | 3 +- .../src/test/x/SLoggingTest/ListHandler.x | 29 ++---- .../test/x/SLoggingTest/LoggerContextTest.x | 4 +- .../test/x/SLoggingTest/MemoryHandlerTest.x | 6 +- .../test/x/SLoggingTest/SourceLocationTest.x | 2 +- .../src/test/x/SLoggingTest/TrackingHandler.x | 24 ++--- .../src/test/x/SLoggingTest/WithGroupTest.x | 34 +++---- .../src/test/x/SLoggingTest/WithTest.x | 30 +++--- 29 files changed, 292 insertions(+), 346 deletions(-) diff --git a/lib_slogging/build.gradle.kts b/lib_slogging/build.gradle.kts index 5f2bcc475a..d1ae7ef9b3 100644 --- a/lib_slogging/build.gradle.kts +++ b/lib_slogging/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { xdkJavaTools(libs.javatools) xtcModule(libs.xdk.ecstasy) + xtcModule(libs.xdk.convert) xtcModule(libs.xdk.json) xtcModuleTest(libs.javatools.bridge) xtcModuleTest(libs.xdk.xunit.engine) diff --git a/lib_slogging/src/main/x/slogging.x b/lib_slogging/src/main/x/slogging.x index 0d9f3fff2d..273ad03c35 100644 --- a/lib_slogging/src/main/x/slogging.x +++ b/lib_slogging/src/main/x/slogging.x @@ -60,38 +60,39 @@ * lib_logging/src/main/x/logging.x — the SLF4J-shaped sibling library */ module slogging.xtclang.org { - package json import json.xtclang.org; + package convert import convert.xtclang.org; + package json import json.xtclang.org; /** * Lazy message supplier used by [Logger]. * * The logger invokes this function only after [Handler.enabled] accepts the record's - * level. This is the slog-shaped equivalent of Kotlin logging blocks and Java - * supplier-based logging APIs. + * level. */ - typedef function String() as MessageSupplier; + typedef function AnyValue() as MessageSupplier; /** * AnyValue is used to represent various values of specific types in log message attributes. * * An AnyValue is either: * - * - a primitive type: string, boolean, double precision floating point (IEEE 754-1985), or - * signed 64 bit integer, - * - a homogeneous array of primitive type values. A homogeneous array MUST NOT contain values - * of different types. - * - a byte array. + * - a [PrimitiveValue] (Null, Boolean, String, integer or floating-point number, or byte + * array), * - an array of AnyValue, - * - a Mmp, - * - a Null value + * - a Map. * * Arbitrary deep nesting of values for arrays and maps is allowed (essentially allows to * represent an equivalent of a JSON object). * Using array and map values may carry a higher performance overhead compared to primitive * values. */ - typedef Nullable | Boolean | String | Int | IntLiteral | Float | FPLiteral | Byte[] - | AnyValue[] | Map as AnyValue; + typedef PrimitiveValue | AnyValue[] | Map as AnyValue; + + /** + * The various primitive values allowed in an AnyValue. + */ + typedef Nullable | Boolean | String | IntNumber | IntLiteral | Float | FPLiteral | Byte[] + as PrimitiveValue; /** * A map of attribute key-value pairs carried by resources, scopes, and data points. diff --git a/lib_slogging/src/main/x/slogging/AsyncHandler.x b/lib_slogging/src/main/x/slogging/AsyncHandler.x index b7b0eec1bd..86f9498432 100644 --- a/lib_slogging/src/main/x/slogging/AsyncHandler.x +++ b/lib_slogging/src/main/x/slogging/AsyncHandler.x @@ -2,7 +2,7 @@ * Async wrapper for a slog [Handler]. * * Records are fully constructed before they enter this handler, so delayed emission - * preserves attrs, groups, source metadata, and exceptions exactly as the caller + * preserves attributes, groups, source metadata, and exceptions exactly as the caller * produced them. */ service AsyncHandler(Handler delegate, Int capacity = 1024) @@ -23,9 +23,7 @@ service AsyncHandler(Handler delegate, Int capacity = 1024) @RO Int pending.get() = queue.size; @Override - Boolean enabled(Level level) { - return delegate.enabled(level); - } + Boolean enabled(Level level) = delegate.enabled(level); @Override void handle(Record record) { @@ -42,21 +40,17 @@ service AsyncHandler(Handler delegate, Int capacity = 1024) } @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new AsyncHandler(delegate.withAttrs(attrs), capacity); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new AsyncHandler(delegate.withAttributes(attributes), capacity); @Override - Handler withGroup(String name) { - return name == "" ? this : new AsyncHandler(delegate.withGroup(name), capacity); - } + Handler withGroup(String name) + = name.empty ? this : new AsyncHandler(delegate.withGroup(name), capacity); /** * Synchronously drain currently queued records. */ - void flush() { - drain(); - } + void flush() = drain(); /** * Stop accepting new records. By default this drains pending records first. diff --git a/lib_slogging/src/main/x/slogging/BoundHandler.x b/lib_slogging/src/main/x/slogging/BoundHandler.x index 0cde7d3968..8cdd031ef4 100644 --- a/lib_slogging/src/main/x/slogging/BoundHandler.x +++ b/lib_slogging/src/main/x/slogging/BoundHandler.x @@ -4,7 +4,7 @@ * * Go slog puts the power of `Logger.With` and `Logger.WithGroup` at the handler * boundary: a derived logger contains a derived handler. A production handler can - * override [withAttrs] / [withGroup] to pre-render JSON fragments, allocate a cached text + * override [withAttributes] / [withGroup] to pre-render JSON fragments, allocate a cached text * prefix, or fold attributes into a backend-specific context object. `BoundHandler` * supplies the default implementation for handlers that only need correct semantics. * @@ -22,21 +22,21 @@ * * yields both `env` and `amount` inside the `payments` group. */ -const BoundHandler(Handler delegate, Attributes attrs = [], String? groupName = Null) +const BoundHandler(Handler delegate, Attributes attributes = [], String? groupName = Null) implements Handler { /** - * Fast-path level check. Bound attrs and groups never affect enablement. + * Fast-path level check. Bound attributes and groups never affect enablement. */ @Override Boolean enabled(Level level) = delegate.enabled(level); /** - * Apply the bound attrs/group to the record, then forward to the delegate. + * Apply the bound attributes/group to the record, then forward to the delegate. */ @Override void handle(Record record) { - Attributes merged = merge(attrs, record.attrs); + Attributes merged = merge(attributes, record.attributes); if (String group ?= groupName) { if (!merged.empty) { ListMap wrapped = new ListMap(); @@ -46,10 +46,10 @@ const BoundHandler(Handler delegate, Attributes attrs = [], String? groupName = } delegate.handle(new Record( - time = record.time, + timestamp = record.timestamp, message = record.message, level = record.level, - attrs = merged, + attributes = merged, exception = record.exception, sourceFile = record.sourceFile, sourceLine = record.sourceLine, @@ -63,17 +63,15 @@ const BoundHandler(Handler delegate, Attributes attrs = [], String? groupName = * `WithGroup(...).With(...)`. */ @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new BoundHandler(delegate=this, attributes=attributes); /** * Stack another group derivation around this handler. */ @Override - Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(delegate=this, groupName=name); - } + Handler withGroup(String name) + = name.empty ? this : new BoundHandler(delegate=this, groupName=name); /** * Immutable concatenation helper preserving insertion order. @@ -86,12 +84,8 @@ const BoundHandler(Handler delegate, Attributes attrs = [], String? groupName = return first; } ListMap result = new ListMap(first.size + second.size); - for ((String key, AnyValue value) : first) { - result.put(key, value); - } - for ((String key, AnyValue value) : second) { - result.put(key, value); - } + result.putAll(first); + result.putAll(second); return result.makeImmutable(); } } diff --git a/lib_slogging/src/main/x/slogging/Handler.x b/lib_slogging/src/main/x/slogging/Handler.x index 51fcd85379..340583b656 100644 --- a/lib_slogging/src/main/x/slogging/Handler.x +++ b/lib_slogging/src/main/x/slogging/Handler.x @@ -8,12 +8,12 @@ * * Boolean enabled(Level level) — cheap fast-path filter * void handle(Record record) — emit the record - * Handler withAttrs(Attributes attrs) — derive a pre-bound handler + * Handler withAttributes(Attributes attributes) — derive a pre-bound handler * Handler withGroup(String name) — derive a name-prefixed handler * - * The two extra methods exist so that when user code does `logger.with(attrs)` once and + * The two extra methods exist so that when user code does `logger.with(attributes)` once and * then logs millions of records through the derived logger, the handler can do the - * "merge attrs into the namespace" work *once* at derivation time rather than on every + * "merge attributes into the namespace" work *once* at derivation time rather than on every * `handle` call. * * Handlers that do not maintain their own derived representation can return @@ -57,7 +57,7 @@ interface Handler { * [BoundHandler] for the default semantics, or return a handler-specific derived * instance that caches a rendered prefix. */ - Handler withAttrs(Attributes attrs); + Handler withAttributes(Attributes attributes); /** * Return a handler that namespaces subsequent attributes under the supplied group diff --git a/lib_slogging/src/main/x/slogging/HandlerOptions.x b/lib_slogging/src/main/x/slogging/HandlerOptions.x index c66ce4cbf5..bcebf461fa 100644 --- a/lib_slogging/src/main/x/slogging/HandlerOptions.x +++ b/lib_slogging/src/main/x/slogging/HandlerOptions.x @@ -44,20 +44,13 @@ const HandlerOptions( String exceptionKey, ) { - construct(Level rootLevel = Info, String[] redactedKeys = []) { + construct(Level rootLevel = Level.Info, String[] redactedKeys = []) { construct HandlerOptions(rootLevel, redactedKeys, "***", True, - "time", "level", "msg", "source", "exception"); + "timestamp", "level", "msg", "source", "exception"); } /** * True iff the supplied key should be rendered as [redaction]. */ - Boolean redacts(String key) { - for (String candidate : redactedKeys) { - if (candidate == key) { - return True; - } - } - return False; - } + Boolean redacts(String key) = redactedKeys.contains(key); } diff --git a/lib_slogging/src/main/x/slogging/JSONHandler.x b/lib_slogging/src/main/x/slogging/JSONHandler.x index 8f1b80fc84..5cea26c6c0 100644 --- a/lib_slogging/src/main/x/slogging/JSONHandler.x +++ b/lib_slogging/src/main/x/slogging/JSONHandler.x @@ -1,4 +1,7 @@ +import convert.formats.Base64Format; + import json.Doc; +import json.JsonArray; import json.JsonObject; import json.Printer; @@ -19,10 +22,10 @@ const JSONHandler * * @param options (optional) the handler's options * @param groupName (optional) the handler's group name - * @param handler (optional) the consumer that will process the [JsonObject] produced from + * @param consumer (optional) the consumer that will process the [JsonObject] produced from * the log [Record] (the default will print the json to the console) */ - construct (HandlerOptions? options = Null, String groupName = "", JsonConsumer? consumer = Null) { + construct(HandlerOptions? options = Null, String groupName = "", JsonConsumer? consumer = Null) { this.options = options ?: new HandlerOptions(); this.groupName = groupName; this.consumer = consumer ?: defaultConsumer; @@ -40,17 +43,13 @@ const JSONHandler * Cheap threshold check; no JSON work happens for disabled records. */ @Override - Boolean enabled(Level level) { - return level.severity >= options.rootLevel.severity; - } + Boolean enabled(Level level) = level.enabledAtThreshold(options.rootLevel); /** * Render and print one JSON line. */ @Override - void handle(Record record) { - consumer(toJson(record)); - } + void handle(Record record) = consumer(toJson(record)); private static void defaultConsumer(JsonObject obj) { @Inject Console console; @@ -62,13 +61,14 @@ const JSONHandler * only prints the rendered document. */ JsonObject toJson(Record record) { - JsonObject obj = json.newObject(); - obj.put(options.timeKey, record.time.toString()); + JsonObject obj = json.newObject(); + IntLiteral nanos = (record.timestamp.epochPicos / 1000).toIntLiteral(); // epoch nanos + obj.put(options.timeKey, nanos); obj.put(options.levelKey, record.level.label); - obj.put(options.messageKey, record.message); + obj.put(options.messageKey, encodeAnyValue(record.message)); - JsonObject attrTarget = groupName == "" ? obj : ensureObject(obj, groupName); - addAttrs(attrTarget, record.attrs); + JsonObject attrTarget = groupName.empty ? obj : ensureObject(obj, groupName); + addAttributes(attrTarget, record.attributes); if (Exception e ?= record.exception) { obj.put(options.exceptionKey, exceptionJson(e)); @@ -85,7 +85,7 @@ const JSONHandler } } - if (record.threadName != "") { + if (!record.threadName.empty) { obj.put("thread", record.threadName); } @@ -93,36 +93,34 @@ const JSONHandler } /** - * Return a handler with pre-bound attrs. This implementation uses [BoundHandler]; + * Return a handler with pre-bound attributes. This implementation uses [BoundHandler]; * a lower-level production sink could override this method to cache a serialized * prefix instead. */ @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new BoundHandler(delegate=this, attributes=attributes); /** - * Return a handler that nests subsequent attrs under `name`. + * Return a handler that nests subsequent attributes under `name`. */ @Override - Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(delegate=this, groupName=name); - } + Handler withGroup(String name) + = name.empty ? this : new BoundHandler(delegate=this, groupName=name); /** - * Add all attrs into a JSON object, preserving slog groups as nested objects. + * Add all attributes into a JSON object, preserving slog groups as nested objects. */ - private void addAttrs(JsonObject obj, Attributes attrs) { - for ((String key, AnyValue value) : attrs) { - obj.put(key, options.redacts(key) ? options.redaction : attrValue(value)); + private void addAttributes(JsonObject obj, Attributes attributes) { + for ((String key, AnyValue value) : attributes) { + obj.put(key, options.redacts(key) ? options.redaction : encodeAnyValue(value)); } } /** - * Convert an attribute value to a JSON document. + * Convert an [AnyValue] value to a JSON document. */ - private Doc attrValue(AnyValue value) { + private Doc encodeAnyValue(AnyValue value) { if (String s := value.is(String)) { return s; } @@ -141,13 +139,24 @@ const JSONHandler if (FPNumber n := value.is(FPNumber)) { return n.toFPLiteral(); } - if (Map nested := value.is(Map)) { - JsonObject obj = json.newObject(); - addAttrs(obj, nested); - return obj.makeImmutable(); + if (value.is(Byte[])) { + return Base64Format.Instance.encode(value); } - - return value.toString(); + if (value.is(AnyValue[])) { + JsonArray array = json.newArray(); + for (AnyValue element : value) { + array.add(encodeAnyValue(element)); + } + return array; + } + if (value.is(Map)) { + JsonObject entries = json.newObject(); + for ((String k, AnyValue v) : value) { + entries.put(k, encodeAnyValue(v)); + } + return entries; + } + assert as $"Unhandled AnyValue to JSON conversion {&value.type}"; } /** diff --git a/lib_slogging/src/main/x/slogging/Level.x b/lib_slogging/src/main/x/slogging/Level.x index 1e16951950..8762750c12 100644 --- a/lib_slogging/src/main/x/slogging/Level.x +++ b/lib_slogging/src/main/x/slogging/Level.x @@ -50,9 +50,7 @@ const Level(Int severity, String label) * `threshold`. Matches the SLF4J library's [logging.Level.enabledAtThreshold] for * caller convenience. */ - Boolean enabledAtThreshold(Level threshold) { - return severity >= threshold.severity; - } + Boolean enabledAtThreshold(Level threshold) = severity >= threshold.severity; /** * Natural ordering is severity ordering. diff --git a/lib_slogging/src/main/x/slogging/Logger.x b/lib_slogging/src/main/x/slogging/Logger.x index 916939f270..fc600b5ccf 100644 --- a/lib_slogging/src/main/x/slogging/Logger.x +++ b/lib_slogging/src/main/x/slogging/Logger.x @@ -38,7 +38,7 @@ * record construction, forward to handler), and [logAt] provides explicit source metadata * for callers or future compiler/runtime sugar. Runtime injection for * `@Inject slogging.Logger logger;` is registered by `NativeContainer`. There is no - * `LogAttrs(ctx, level, msg, attrs...)` form; [LoggerContext] is the Ecstasy-shaped + * `LogAttributes(ctx, level, msg, attributes...)` form; [LoggerContext] is the Ecstasy-shaped * optional context helper. */ const Logger @@ -47,15 +47,13 @@ const Logger @Inject Clock clock; /** - * Compatibility convenience: handler plus pre-bound attrs. The attrs are handed to - * [Handler.withAttrs] immediately, which is the slog contract and what allows a + * Compatibility convenience: handler plus pre-bound attributes. The attributes are handed to + * [Handler.withAttributes] immediately, which is the slog contract and what allows a * handler to pre-render or cache its own derived state. */ - construct(Handler? handler = Null, Attributes attrs = []) { - this.handler = handler ?: new TextHandler(); - if (!attrs.empty) { - this.handler = this.handler.withAttrs(attrs); - } + construct(Handler? handler = Null, Attributes attributes = []) { + Handler base = handler ?: new TextHandler(); + this.handler = attributes.empty ? base : base.withAttributes(attributes); } Handler handler; @@ -90,71 +88,70 @@ const Logger /** * Emit a `Debug` record. The message is already a complete human-readable string; - * structured values belong in `extra` attrs rather than `{}` placeholders. See + * structured values belong in `extra` attributes rather than `{}` placeholders. See * `doc/logging/usage/slog-parity.md` § "Message formatting". */ - void debug(String message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Debug, message, extra, cause); + void debug(String message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Debug, message, extra, cause); /** * Emit a `Debug` record whose message is computed after the handler's enabled check. */ - void debug(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Debug, message, extra, cause); + void debug(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Debug, message, extra, cause); /** * Emit an `Info` record. */ - void info(String message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Info, message, extra, cause); + void info(String message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Info, message, extra, cause); /** * Emit an `Info` record whose message is computed after the handler's enabled check. */ - void info(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Info, message, extra, cause); + void info(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Info, message, extra, cause); /** * Emit a `Warn` record. */ - void warn(String message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Warn, message, extra, cause); + void warn(String message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Warn, message, extra, cause); /** * Emit a `Warn` record whose message is computed after the handler's enabled check. */ - void warn(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Warn, message, extra, cause); + void warn(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Warn, message, extra, cause); /** * Emit an `Error` record. `cause` is an Ecstasy convenience for a thrown exception. */ - void error(String message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Error, message, extra, cause); + void error(String message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Error, message, extra, cause); /** * Emit an `Error` record whose message is computed after the handler's enabled check. */ - void error(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) = - log(Level.Error, message, extra, cause); + void error(MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) + = log(Level.Error, message, extra, cause); /** * Open-level emission. Use for custom levels. * * The enabled check is first. If the handler rejects `level`, no record is - * constructed and no attrs are merged. This mirrors Go slog's + * constructed and no attributes are merged. This mirrors Go slog's * `Handler.Enabled` fast path. */ - void log(Level level, String message, Attributes extra = Map:[], Exception? cause = Null) { - emit(level, message, extra, cause, Null, -1); - } + void log(Level level, String message, Attributes extra = Map:[], Exception? cause = Null) + = emit(level, message, extra, cause, Null, -1); /** * Open-level emission with lazy message construction. */ - void log(Level level, MessageSupplier message, Attributes extra = Map:[], Exception? cause = Null) { - emit(level, message, extra, cause, Null, -1); - } + void log(Level level, MessageSupplier message, Attributes extra = Map:[], + Exception? cause = Null) + = emit(level, message, extra, cause, Null, -1); /** * Open-level emission with explicit source metadata. @@ -165,32 +162,30 @@ const Logger * caller-site sugar into this method without changing the handler contract. */ void logAt(Level level, String message, String sourceFile, Int sourceLine, - Attributes extra = Map:[], Exception? cause = Null) { - emit(level, message, extra, cause, sourceFile, sourceLine); - } + Attributes extra = Map:[], Exception? cause = Null) + = emit(level, message, extra, cause, sourceFile, sourceLine); /** * Open-level emission with explicit source metadata and lazy message construction. */ void logAt(Level level, MessageSupplier message, String sourceFile, Int sourceLine, - Attributes extra = Map:[], Exception? cause = Null) { - emit(level, message, extra, cause, sourceFile, sourceLine); - } + Attributes extra = Map:[], Exception? cause = Null) + = emit(level, message, extra, cause, sourceFile, sourceLine); /** * Common emission path used by [log] and [logAt]. The handler owns any attributes * attached by derived loggers; the record receives only call-time extras. */ private void emit(Level level, String message, Attributes extra, Exception? cause, - String? sourceFile, Int sourceLine) { + String? sourceFile, Int sourceLine) { if (!handler.enabled(level)) { return; } handler.handle(new Record( - time = clock.now, + timestamp = clock.now, message = message, level = level, - attrs = extra, + attributes = extra, exception = cause, sourceFile = sourceFile, sourceLine = sourceLine, @@ -202,15 +197,15 @@ const Logger * accepts the level, matching Go slog's `Enabled` fast path. */ private void emit(Level level, MessageSupplier message, Attributes extra, Exception? cause, - String? sourceFile, Int sourceLine) { + String? sourceFile, Int sourceLine) { if (!handler.enabled(level)) { return; } handler.handle(new Record( - time = clock.now, + timestamp = clock.now, message = message(), level = level, - attrs = extra, + attributes = extra, exception = cause, sourceFile = sourceFile, sourceLine = sourceLine, @@ -221,19 +216,19 @@ const Logger * Return a derived logger that carries the supplied attributes. Equivalent to * `slog.Logger.With(...)`. * - * The attrs are not stored in `Logger`; they live in the derived [Handler]. That is - * the key slog design point: a handler can pre-resolve attrs once at derivation time + * The attributes are not stored in `Logger`; they live in the derived [Handler]. That is + * the key slog design point: a handler can pre-resolve attributes once at derivation time * instead of merging or rendering them on every emission. * * This is the slog replacement for SLF4J MDC in the core API: instead of hidden * fiber-local state, code passes around a logger that visibly carries request or - * component attrs. See `doc/logging/usage/slog-parity.md`. + * component attributes. See `doc/logging/usage/slog-parity.md`. */ Logger with(Attributes more) { if (more.empty) { return this; } - return new Logger(handler.withAttrs(more)); + return new Logger(handler.withAttributes(more)); } /** @@ -244,7 +239,7 @@ const Logger * (`payments.amount`), while JSON handlers nest (`{"payments":{"amount":...}}`). */ Logger withGroup(String groupName) { - if (groupName == "") { + if (groupName.empty) { return this; } return new Logger(handler.withGroup(groupName)); diff --git a/lib_slogging/src/main/x/slogging/LoggerContext.x b/lib_slogging/src/main/x/slogging/LoggerContext.x index a8b40eff48..2bfcb829ee 100644 --- a/lib_slogging/src/main/x/slogging/LoggerContext.x +++ b/lib_slogging/src/main/x/slogging/LoggerContext.x @@ -4,7 +4,7 @@ import ecstasy.SharedContext; * Optional context helper for code that wants Go-slog-style request propagation without * passing a logger through every method signature. * - * Go slog accepts `context.Context` on `LogAttrs` and `Handler.Enabled` / `Handle`. + * Go slog accepts `context.Context` on `LogAttributes` and `Handler.Enabled` / `Handle`. * Ecstasy does not need to copy that exact API to be familiar: [SharedContext] already * models a logical execution context that flows across service calls and child fibers. * `LoggerContext` is the smallest explicit bridge: @@ -35,9 +35,7 @@ const LoggerContext { * * Use the returned token with `using` so the previous logger is restored reliably. */ - SharedContext.Token bind(Logger logger) { - return context.withValue(logger); - } + SharedContext.Token bind(Logger logger) = context.withValue(logger); /** * Return the context logger when one is bound. @@ -47,7 +45,5 @@ const LoggerContext { /** * Return the context logger, or `fallback` when no context logger is bound. */ - Logger currentOr(Logger fallback) { - return context.hasValue() ?: fallback; - } + Logger currentOr(Logger fallback) = context.hasValue() ?: fallback; } diff --git a/lib_slogging/src/main/x/slogging/MemoryHandler.x b/lib_slogging/src/main/x/slogging/MemoryHandler.x index a461a7ae65..542f390241 100644 --- a/lib_slogging/src/main/x/slogging/MemoryHandler.x +++ b/lib_slogging/src/main/x/slogging/MemoryHandler.x @@ -25,55 +25,45 @@ service MemoryHandler * Mutable backing storage. Internal — Ecstasy forbids returning a mutable array * from a service call, so we expose [records] as an immutable snapshot below. */ - private Record[] recordList = new Array(); + private Record[] recordList = new Record[]; /** * Immutable snapshot of the captured records, in emission order. Each access * copies — same pattern as `lib_logging.MemoryLogSink.events`. */ - @RO Record[] records.get() { - return recordList.toArray(Constant); - } + @RO Record[] records.get() = recordList.toArray(Constant); /** * Cheap threshold check. Defaults to `Debug`, matching Go slog's lowest canonical * level and making tests capture everything unless they opt into a stricter level. */ @Override - Boolean enabled(Level level) { - return level.severity >= rootLevel.severity; - } + Boolean enabled(Level level) = level.enabledAtThreshold(rootLevel); /** * Capture the record in memory. This is the slog-shaped analogue of Logback's * `ListAppender` and `lib_logging.MemoryLogSink`. */ @Override - void handle(Record record) { - recordList.add(record); - } + void handle(Record record) = recordList.add(record); /** * Derived loggers share this capture buffer, but [BoundHandler] applies the - * pre-bound attrs before the record reaches [handle]. + * pre-bound attributes before the record reaches [handle]. */ @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new BoundHandler(delegate=this, attributes=attributes); /** * Group derived records while keeping the same capture buffer. */ @Override - Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(delegate=this, groupName=name); - } + Handler withGroup(String name) + = name.empty ? this : new BoundHandler(delegate=this, groupName=name); /** * Discard all captured records. */ - void reset() { - recordList.clear(); - } + void reset() = recordList.clear(); } diff --git a/lib_slogging/src/main/x/slogging/NopHandler.x b/lib_slogging/src/main/x/slogging/NopHandler.x index 1251858d14..e9cf6d8896 100644 --- a/lib_slogging/src/main/x/slogging/NopHandler.x +++ b/lib_slogging/src/main/x/slogging/NopHandler.x @@ -31,7 +31,7 @@ const NopHandler * Deriving a no-op handler is still no-op. */ @Override - Handler withAttrs(Attributes attrs) = this; + Handler withAttributes(Attributes attributes) = this; /** * Grouping a no-op handler is still no-op. diff --git a/lib_slogging/src/main/x/slogging/Record.x b/lib_slogging/src/main/x/slogging/Record.x index e0052ec0d6..8a8d6323a7 100644 --- a/lib_slogging/src/main/x/slogging/Record.x +++ b/lib_slogging/src/main/x/slogging/Record.x @@ -4,30 +4,34 @@ * * Equivalent to the SLF4J-shaped library's [logging.LogEvent], but flattened: there is * no separate `marker` or `keyValues` — categorisation and structured fields all live - * in `attrs`. + * in `attributes`. * - * The `attrs` map carries the structured data visible to the current handler call. - * Derived handlers may prepend attrs or wrap them in groups before forwarding to the - * final backend. This matches Go slog's `Handler.WithAttrs` / `WithGroup` model. + * The `attributes` map carries the structured data visible to the current handler call. + * Derived handlers may prepend attributes or wrap them in groups before forwarding to the + * final backend. This matches Go slog's `Handler.WithAttributes` / `WithGroup` model. * * Source-location capture (`sourceFile`, `sourceLine`) is opt-in. The default is * `(Null, -1)`, meaning "not captured." [Logger.logAt] populates these fields * explicitly; future compiler/runtime sugar can lower call-site metadata into that API. + * + * @param timestamp the time when the log event occurred as measured by the origin clock + * @param message value containing the body of the log record. Can be for example a + * human-readable string message (including multi-line) describing the event in a + * free form or it can be a structured data composed of arrays and maps of other + * values + * @param level the severity of the log event + * @param attributes additional information about the specific event occurrence. Attributes can vary + * for each occurrence of the event coming from the same source + * @param exception optional exception/cause of the log event + * @param sourceFile optional source file name + * @param sourceLine optional source line number + * @param threadName optional thread name */ -const Record( - // Time at which the logger accepted the record. Captured before calling the handler. - Time time, - // Completed human-readable message. Variable data should normally live in attrs. - String message, - // Open integer severity. - Level level, - // Structured data after any handler derivation has been applied. - Attributes attrs, - // Ecstasy-specific convenience for a thrown exception/cause. - Exception? exception = Null, - // Optional source metadata. - String? sourceFile = Null, - Int sourceLine = -1, - // Placeholder for future fiber/thread identity support. - String threadName = "", - ); +const Record(Time timestamp, + AnyValue message, + Level level, + Attributes attributes = [], + Exception? exception = Null, + String? sourceFile = Null, + Int sourceLine = -1, + String threadName = ""); diff --git a/lib_slogging/src/main/x/slogging/TextHandler.x b/lib_slogging/src/main/x/slogging/TextHandler.x index a56242ba18..ea254baadd 100644 --- a/lib_slogging/src/main/x/slogging/TextHandler.x +++ b/lib_slogging/src/main/x/slogging/TextHandler.x @@ -18,15 +18,14 @@ const TextHandler implements Handler { /** - * Create a [JSONHandler]. + * Create a [TextHandler]. * * @param options (optional) the handler's options * @param groupName (optional) the handler's group name - * @param handler (optional) the consumer that will process the String produced from the log - * [Record] (the default will print the json to the console) + * @param consumer (optional) the consumer that will process the String produced from the log + * [Record] (the default will print the line to the console) */ - construct (HandlerOptions? options = Null, String groupName = "", TextConsumer? consumer = Null) - { + construct(HandlerOptions? options = Null, String groupName = "", TextConsumer? consumer = Null) { this.options = options ?: new HandlerOptions(); this.groupName = groupName; this.consumer = consumer ?: defaultConsumer; @@ -52,26 +51,23 @@ const TextHandler * severity; no formatting or attribute walking belongs on the fast path. */ @Override - Boolean enabled(Level level) { - return level.severity >= options.rootLevel.severity; - } + Boolean enabled(Level level) = level.enabledAtThreshold(options.rootLevel); /** * Render a record as one line of text. This is the POC equivalent of Go - * `slog.TextHandler.Handle`: the message is quoted and attrs are appended as + * `slog.TextHandler.Handle`: the message is quoted and attributes are appended as * `key=value` pairs. */ @Override void handle(Record record) { StringBuffer buf = new StringBuffer(); - buf.append(record.time.toString()) + buf.append(record.timestamp.toString()) .append(' ') .append(record.level.label.leftJustify(5, ' ')) .append(' ') - .append("msg=") - .append(quote(record.message)); + .append(record.message); - appendAttrs(buf, groupName, record.attrs); + appendAttributes(buf, groupName, record.attributes); appendSource(buf, record); consumer(buf.toString()); @@ -83,14 +79,12 @@ const TextHandler } @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new BoundHandler(delegate=this, attrs=attrs); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new BoundHandler(delegate=this, attributes=attributes); @Override - Handler withGroup(String name) { - return name == "" ? this : new BoundHandler(delegate=this, groupName=name); - } + Handler withGroup(String name) + = name.empty ? this : new BoundHandler(delegate=this, groupName=name); private static void defaultConsumer(String text) { @Inject Console console; @@ -103,9 +97,9 @@ const TextHandler * separator — matches `slog.TextHandler`. */ private void renderAttr(StringBuffer buf, String prefix, String key, AnyValue value) { - String fullKey = prefix == "" ? key : $"{prefix}.{key}"; + String fullKey = prefix.empty ? key : $"{prefix}.{key}"; if (value.is(Map)) { - appendNestedAttrs(buf, fullKey, value); + appendNestedAttributes(buf, fullKey, value); } else { buf.append(fullKey).append('=') .append(options.redacts(key) ? options.redaction : value.toString()); @@ -113,11 +107,11 @@ const TextHandler } /** - * Append top-level attrs. Each attr starts with a leading space because the base + * Append top-level attributes. Each attr starts with a leading space because the base * record fields have already been rendered. */ - private void appendAttrs(StringBuffer buf, String prefix, Attributes attrs) { - for ((String key, AnyValue value) : attrs) { + private void appendAttributes(StringBuffer buf, String prefix, Attributes attributes) { + for ((String key, AnyValue value) : attributes) { buf.append(' '); renderAttr(buf, prefix, key, value); } @@ -127,9 +121,9 @@ const TextHandler * Append children of an attr group. The first child continues in the current * position; later children are separated with spaces. */ - private void appendNestedAttrs(StringBuffer buf, String prefix, Map attrs) { + private void appendNestedAttributes(StringBuffer buf, String prefix, Map attributes) { Boolean first = True; - for ((String key, AnyValue value) : attrs) { + for ((String key, AnyValue value) : attributes) { if (!first) { buf.append(' '); } @@ -154,10 +148,4 @@ const TextHandler } } } - - /** - * POC string quoting. Production text output should escape quotes, newlines, and - * separators; that belongs with the production handler, not the API sketch. - */ - private String quote(String s) = $"\"{s}\""; } diff --git a/lib_slogging/src/test/x/SLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest.x index 2f446cbf6e..4f5fb58a37 100644 --- a/lib_slogging/src/test/x/SLoggingTest.x +++ b/lib_slogging/src/test/x/SLoggingTest.x @@ -14,7 +14,7 @@ * - `Logger.logAt(...)` populates explicit source metadata; * - `LoggerContext` propagates request-scoped loggers; * - `JSONHandler` renders parseable `lib_json` documents; - * - `HandlerContract` checks `withAttrs` / `withGroup` conformance; + * - `HandlerContract` checks `withAttributes` / `withGroup` conformance; * - nested attribute maps render as nested structure; * - custom `Level` values comparable to / between the canonical four work. */ diff --git a/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x b/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x index 3cbe78ba42..58df074e8d 100644 --- a/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/CountingHandler.x @@ -1,4 +1,5 @@ import slogging.Attributes; +import slogging.BoundHandler; import slogging.Handler; import slogging.Level; import slogging.Record; @@ -14,9 +15,7 @@ service CountingHandler public/private Map counts = new HashMap(); @Override - Boolean enabled(Level level) { - return True; - } + Boolean enabled(Level level) = True; @Override void handle(Record record) { @@ -27,12 +26,10 @@ service CountingHandler } @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new slogging.BoundHandler(delegate=this, attrs=attrs); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new BoundHandler(delegate=this, attributes=attributes); @Override - Handler withGroup(String name) { - return name == "" ? this : new slogging.BoundHandler(delegate=this, groupName=name); - } + Handler withGroup(String name) + = name.empty ? this : new BoundHandler(delegate=this, groupName=name); } diff --git a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x index 5224089351..9021c61a2e 100644 --- a/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/EmissionTest.x @@ -16,8 +16,8 @@ class EmissionTest { assert handler.records.size == 1; assert handler.records[0].level == Level.Info; assert handler.records[0].message == "hello"; - assert handler.records[0].attrs.size == 1; - assert handler.records[0].attrs.contains("count"); + assert handler.records[0].attributes.size == 1; + assert handler.records[0].attributes.contains("count"); } @Test diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x b/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x index d4116f04ff..6c1f212b49 100644 --- a/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerContract.x @@ -7,31 +7,31 @@ import slogging.Record; /** * Minimal slogtest-style contract helpers for third-party handlers. * - * Go ships `testing/slogtest` so backend authors can prove that `WithAttrs` and + * Go ships `testing/slogtest` so backend authors can prove that `WithAttributes` and * `WithGroup` are implemented correctly. This helper is the Ecstasy POC equivalent: * pass a handler and a snapshot function for the records it emitted. */ class HandlerContract { /** - * Verify that `withAttrs` prepends bound attributes to call-time attributes. + * Verify that `withAttributes` prepends bound attributes to call-time attributes. */ - static void assertWithAttrsPrepend(Handler root, function Record[] () records) { - Handler derived = root.withAttrs(Map:["requestId"="r_1"]); + static void assertWithAttributesPrepend(Handler root, function Record[] () records) { + Handler derived = root.withAttributes(Map:["requestId"="r_1"]); derived.handle(sample(Map:["path"="/checkout"])); Record[] captured = records(); assert captured.size == 1; - Attributes attrs = captured[0].attrs; - assert attrs.size == 2; - String[] keys = attrs.keys.toArray(); + Attributes attributes = captured[0].attributes; + assert attributes.size == 2; + String[] keys = attributes.keys.toArray(); assert keys[0] == "requestId"; - assert attrs["requestId"] == "r_1"; + assert attributes["requestId"] == "r_1"; assert keys[1] == "path"; } /** - * Verify that `withGroup` nests subsequent attrs under the group name. + * Verify that `withGroup` nests subsequent attributes under the group name. */ static void assertWithGroupNests(Handler root, function Record[] () records) { Handler grouped = root.withGroup("payments"); @@ -39,9 +39,9 @@ class HandlerContract { Record[] captured = records(); assert captured.size == 1; - Attributes attrs = captured[0].attrs; - assert attrs.size == 1; - AnyValue paymentsValue = attrs["payments"] ?: assert; + Attributes attributes = captured[0].attributes; + assert attributes.size == 1; + AnyValue paymentsValue = attributes["payments"] ?: assert; assert paymentsValue.is(Map); Map children = paymentsValue.as(Map); @@ -52,12 +52,12 @@ class HandlerContract { /** * Shared sample record. */ - private static Record sample(Attributes attrs) { + private static Record sample(Attributes attributes) { return new Record( - time = new Time("2019-05-22T120123.456Z"), - message = "event", - level = Level.Info, - attrs = attrs, + timestamp = new Time("2019-05-22T120123.456Z"), + message = "event", + level = Level.Info, + attributes = attributes, ); } } diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x b/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x index e1952e539b..d8852986f3 100644 --- a/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerContractTest.x @@ -10,7 +10,7 @@ class HandlerContractTest { void shouldValidateMemoryHandlerDerivations() { MemoryHandler handler = new MemoryHandler(); - HandlerContract.assertWithAttrsPrepend(handler, () -> handler.records); + HandlerContract.assertWithAttributesPrepend(handler, () -> handler.records); handler.reset(); diff --git a/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x b/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x index b07c93c6d9..ee960ec87a 100644 --- a/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/HandlerDerivationTest.x @@ -8,16 +8,16 @@ import slogging.Logger; class HandlerDerivationTest { @Test - void shouldCallHandlerWithAttrsWhenDerivingLogger() { + void shouldCallHandlerWithAttributesWhenDerivingLogger() { TrackingHandler handler = new TrackingHandler(); Logger base = new Logger(handler); Logger derived = base.with(Map:["requestId"="r_1"]); derived.info("processing"); - assert handler.withAttrsCalls == 1; - assert handler.lastAttrs.size == 1; - assert handler.lastAttrs["requestId"] == "r_1"; + assert handler.withAttributesCalls == 1; + assert handler.lastAttributes.size == 1; + assert handler.lastAttributes["requestId"] == "r_1"; assert handler.records.size == 1; } diff --git a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x index c4926fe6e2..e072fb95f0 100644 --- a/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/JSONHandlerTest.x @@ -18,14 +18,14 @@ class JSONHandlerTest { void shouldRenderParseableJsonWithEscapingAndNestedGroups() { JSONHandler handler = new JSONHandler(); Exception boom = new Exception("bad \"card\""); - Map userAttrs = Map:["id"="u_3"]; + Map userAttributes = Map:["id"="u_3"]; Record record = new Record( - time = new Time("2019-05-22T120123.456Z"), + timestamp = new Time("2019-05-22T120123.456Z"), message = "charged \"ok\"", level = Level.Info, - attrs = Map:[ + attributes = Map:[ "amount" = 1099, - "user" = userAttrs, + "user" = userAttributes, ], exception = boom, sourceFile = "PaymentService.x", @@ -55,12 +55,12 @@ class JSONHandlerTest { @Test void shouldRenderGroupedDerivedLoggerAsNestedJson() { JSONHandler handler = new JSONHandler(); - Map paymentAttrs = Map:["amount"=1099]; + Map paymentAttributes = Map:["amount"=1099]; Record record = new Record( - time = new Time("2019-05-22T120123.456Z"), - message = "charged", - level = Level.Info, - attrs = Map:["payments"=paymentAttrs], + timestamp = new Time("2019-05-22T120123.456Z"), + message = "charged", + level = Level.Info, + attributes = Map:["payments"=paymentAttributes], ); JsonObject obj = handler.toJson(record); @@ -75,10 +75,10 @@ class JSONHandlerTest { JSONHandler handler = new JSONHandler( new HandlerOptions(Level.Info, ["token"])); Record record = new Record( - time = new Time("2019-05-22T120123.456Z"), - message = "auth", - level = Level.Info, - attrs = Map:["token"="secret", "user"="u_1"], + timestamp = new Time("2019-05-22T120123.456Z"), + message = "auth", + level = Level.Info, + attributes = Map:["token"="secret", "user"="u_1"], ); JsonObject obj = handler.toJson(record); diff --git a/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x b/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x index 4890c2cbf1..5878971f83 100644 --- a/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/LazyLoggingTest.x @@ -1,3 +1,4 @@ +import slogging.Level; import slogging.Logger; /** @@ -12,7 +13,7 @@ class LazyLoggingTest { Logger logger = new Logger(handler); @Volatile Int calls = 0; - handler.setLevel(slogging.Level.Info); + handler.setLevel(Level.Info); logger.debug(() -> { ++calls; return "expensive debug message"; diff --git a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x index 6a2f50a211..9909bb2e6c 100644 --- a/lib_slogging/src/test/x/SLoggingTest/ListHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/ListHandler.x @@ -1,4 +1,5 @@ import slogging.Attributes; +import slogging.BoundHandler; import slogging.Handler; import slogging.Level; import slogging.Record; @@ -17,7 +18,7 @@ import slogging.Record; service ListHandler implements Handler { - public/private Level rootLevel = slogging.Level.Debug; + public/private Level rootLevel = Level.Debug; /** * Mutable backing storage. Internal — Ecstasy forbids returning a mutable array @@ -29,35 +30,25 @@ service ListHandler * Immutable snapshot of the captured records, in emission order. Each access * copies — same pattern as `lib_logging.MemoryLogSink.events`. */ - @RO Record[] records.get() { - return recordList.toArray(Constant); - } + @RO Record[] records.get() = recordList.toArray(Constant); @Override - Boolean enabled(Level level) { - return level.severity >= rootLevel.severity; - } + Boolean enabled(Level level) = level.enabledAtThreshold(rootLevel); @Override - void handle(Record record) { - recordList.add(record); - } + void handle(Record record) = recordList.add(record); @Override - Handler withAttrs(Attributes attrs) { - return attrs.empty ? this : new slogging.BoundHandler(delegate=this, attrs=attrs); - } + Handler withAttributes(Attributes attributes) + = attributes.empty ? this : new BoundHandler(delegate=this, attributes=attributes); @Override - Handler withGroup(String name) { - return name == "" ? this : new slogging.BoundHandler(delegate=this, groupName=name); - } + Handler withGroup(String name) + = name.empty ? this : new BoundHandler(delegate=this, groupName=name); void setLevel(Level level) { rootLevel = level; } - void reset() { - recordList.clear(); - } + void reset() = recordList.clear(); } diff --git a/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x b/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x index f9ab92eb62..fd9598f651 100644 --- a/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/LoggerContextTest.x @@ -20,8 +20,8 @@ class LoggerContextTest { } assert handler.records.size == 1; - assert handler.records[0].attrs.contains("requestId"); - assert handler.records[0].attrs["requestId"] == "r_1"; + assert handler.records[0].attributes.contains("requestId"); + assert handler.records[0].attributes["requestId"] == "r_1"; assert !context.current(); } diff --git a/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x b/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x index 82fbaaad06..68d2657e32 100644 --- a/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/MemoryHandlerTest.x @@ -9,7 +9,7 @@ import slogging.MemoryHandler; class MemoryHandlerTest { @Test - void shouldCaptureRecordWithAttrs() { + void shouldCaptureRecordWithAttributes() { MemoryHandler handler = new MemoryHandler(); Logger logger = new Logger(handler); @@ -18,8 +18,8 @@ class MemoryHandlerTest { assert handler.records.size == 1; assert handler.records[0].level == Level.Info; assert handler.records[0].message == "processed"; - assert handler.records[0].attrs.size == 1; - assert handler.records[0].attrs.contains("count"); + assert handler.records[0].attributes.size == 1; + assert handler.records[0].attributes.contains("count"); } @Test diff --git a/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x b/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x index 9c0ad24824..9f5c4e912d 100644 --- a/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/SourceLocationTest.x @@ -17,7 +17,7 @@ class SourceLocationTest { assert handler.records.size == 1; assert handler.records[0].sourceFile == "PaymentService.x"; assert handler.records[0].sourceLine == 77; - assert handler.records[0].attrs.contains("elapsedMs"); + assert handler.records[0].attributes.contains("elapsedMs"); } @Test diff --git a/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x b/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x index 00e7ddc8ac..fc05e9caf6 100644 --- a/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x +++ b/lib_slogging/src/test/x/SLoggingTest/TrackingHandler.x @@ -4,37 +4,31 @@ import slogging.Level; import slogging.Record; /** - * Test-only handler that records derivation hooks. It makes the `Handler.withAttrs` / + * Test-only handler that records derivation hooks. It makes the `Handler.withAttributes` / * `Handler.withGroup` part of the slog contract observable without depending on a * concrete renderer. */ service TrackingHandler implements Handler { - public/private Int withAttrsCalls = 0; + public/private Int withAttributesCalls = 0; public/private Int withGroupCalls = 0; - public/private Attributes lastAttrs = Map:[]; + public/private Attributes lastAttributes = Map:[]; public/private String[] groups = []; private Record[] recordList = new Record[]; - @RO Record[] records.get() { - return recordList.toArray(Constant); - } + @RO Record[] records.get() = recordList.toArray(Constant); @Override - Boolean enabled(Level level) { - return True; - } + Boolean enabled(Level level) = True; @Override - void handle(Record record) { - recordList.add(record); - } + void handle(Record record) = recordList.add(record); @Override - Handler withAttrs(Attributes attrs) { - ++withAttrsCalls; - lastAttrs = attrs.makeImmutable(); + Handler withAttributes(Attributes attributes) { + ++withAttributesCalls; + lastAttributes = attributes.makeImmutable(); return this; } diff --git a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x index 0feefcebd2..4475e57294 100644 --- a/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/WithGroupTest.x @@ -23,45 +23,45 @@ class WithGroupTest { } @Test - void shouldGroupOnlySubsequentAttrs() { + void shouldGroupOnlySubsequentAttributes() { ListHandler handler = new ListHandler(); Logger base = new Logger(handler).with(Map:["env"="prod"]); Logger grouped = base.withGroup("payments"); grouped.info("charged", Map:["amount"=1099]); - // Matches Go slog ordering: attrs bound before WithGroup stay outside the group; - // attrs supplied after WithGroup are nested under the group. + // Matches Go slog ordering: attributes bound before WithGroup stay outside the group; + // attributes supplied after WithGroup are nested under the group. assert handler.records.size == 1; - Attributes attrs = handler.records[0].attrs; - assert attrs.size == 2; - String[] keys = attrs.keys.toArray(); + Attributes attributes = handler.records[0].attributes; + assert attributes.size == 2; + String[] keys = attributes.keys.toArray(); assert keys[0] == "env"; assert keys[1] == "payments"; - AnyValue paymentsValue = attrs["payments"] ?: assert; + AnyValue paymentsValue = attributes["payments"] ?: assert; assert paymentsValue.is(Map); - Map paymentAttrs = paymentsValue.as(Map); - assert paymentAttrs.size == 1; - assert paymentAttrs["amount"] == 1099; + Map paymentAttributes = paymentsValue.as(Map); + assert paymentAttributes.size == 1; + assert paymentAttributes["amount"] == 1099; } @Test - void shouldPutAttrsBoundAfterGroupInsideGroup() { + void shouldPutAttributesBoundAfterGroupInsideGroup() { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); Logger grouped = base.withGroup("payments").with(Map:["currency"="SEK"]); grouped.info("charged", Map:["amount"=1099]); - Attributes attrs = handler.records[0].attrs; - assert attrs.size == 1; - AnyValue paymentsValue = attrs["payments"] ?: assert; + Attributes attributes = handler.records[0].attributes; + assert attributes.size == 1; + AnyValue paymentsValue = attributes["payments"] ?: assert; assert paymentsValue.is(Map); - Map paymentAttrs = paymentsValue.as(Map); - assert paymentAttrs.size == 2; - String[] paymentKeys = paymentAttrs.keys.toArray(); + Map paymentAttributes = paymentsValue.as(Map); + assert paymentAttributes.size == 2; + String[] paymentKeys = paymentAttributes.keys.toArray(); assert paymentKeys[0] == "currency"; assert paymentKeys[1] == "amount"; } diff --git a/lib_slogging/src/test/x/SLoggingTest/WithTest.x b/lib_slogging/src/test/x/SLoggingTest/WithTest.x index 328e82c78b..2ec13f5823 100644 --- a/lib_slogging/src/test/x/SLoggingTest/WithTest.x +++ b/lib_slogging/src/test/x/SLoggingTest/WithTest.x @@ -12,7 +12,7 @@ import slogging.Logger; class WithTest { @Test - void shouldCarryAttachedAttrsIntoRecord() { + void shouldCarryAttachedAttributesIntoRecord() { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); @@ -24,11 +24,11 @@ class WithTest { derived.info("processing"); assert handler.records.size == 1; - Attributes attrs = handler.records[0].attrs; - assert attrs.size == 2; - String[] keys = attrs.keys.toArray(); - assert keys[0] == "requestId" && attrs["requestId"] == "r_42"; - assert keys[1] == "user" && attrs["user"] == "u_3"; + Attributes attributes = handler.records[0].attributes; + assert attributes.size == 2; + String[] keys = attributes.keys.toArray(); + assert keys[0] == "requestId" && attributes["requestId"] == "r_42"; + assert keys[1] == "user" && attributes["user"] == "u_3"; } @Test @@ -42,15 +42,15 @@ class WithTest { withB.info("event"); assert handler.records.size == 1; - Attributes attrs = handler.records[0].attrs; - assert attrs.size == 2; - String[] keys = attrs.keys.toArray(); + Attributes attributes = handler.records[0].attributes; + assert attributes.size == 2; + String[] keys = attributes.keys.toArray(); assert keys[0] == "a"; assert keys[1] == "b"; } @Test - void shouldCombineAttachedAttrsWithCallTimeExtras() { + void shouldCombineAttachedAttributesWithCallTimeExtras() { ListHandler handler = new ListHandler(); Logger base = new Logger(handler); @@ -58,9 +58,9 @@ class WithTest { reqLog.info("processing", Map:["path"="/api"]); - Attributes attrs = handler.records[0].attrs; - assert attrs.size == 2; - String[] keys = attrs.keys.toArray(); + Attributes attributes = handler.records[0].attributes; + assert attributes.size == 2; + String[] keys = attributes.keys.toArray(); assert keys[0] == "requestId"; assert keys[1] == "path"; } @@ -75,7 +75,7 @@ class WithTest { base.info("from base"); derived.info("from derived"); - assert handler.records[0].attrs.empty; - assert handler.records[1].attrs.size == 1; + assert handler.records[0].attributes.empty; + assert handler.records[1].attributes.size == 1; } } From e75741afdc707026cd02905269274e6e75466c61 Mon Sep 17 00:00:00 2001 From: Jonathan Knight Date: Fri, 8 May 2026 22:32:53 +0300 Subject: [PATCH 28/28] Update `TestSLogging.x` --- manualTests/src/main/x/TestSLogging.x | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manualTests/src/main/x/TestSLogging.x b/manualTests/src/main/x/TestSLogging.x index bf13fc0b01..8b8399fe79 100644 --- a/manualTests/src/main/x/TestSLogging.x +++ b/manualTests/src/main/x/TestSLogging.x @@ -36,7 +36,7 @@ module TestSLogging { * works because the injector also keys by requested type. */ void runInjectedLogger() { - console.print("--- injected slog Logger: levels, attrs, lazy messages ---"); + console.print("--- injected slog Logger: levels, attributes, lazy messages ---"); @Inject Logger logger; assert logger.infoEnabled; @@ -70,26 +70,26 @@ module TestSLogging { } /** - * The central slog idea: derive loggers with attrs/groups instead of named logger + * The central slog idea: derive loggers with attributes/groups instead of named logger * categories and MDC. */ void runDerivedLoggersAttrs() { console.print("--- derived loggers and groups ---"); MemoryHandler capture = new MemoryHandler(); - Logger root = new Logger(capture); + Logger root = new Logger(handler=capture); Logger payments = root.with(["requestId"="r_slog"]).withGroup("payments"); - Attributes attrs = Map:["card"=["last4"="4242"], "amount"=1099]; - payments.info("charged", attrs); + Attributes attributes = Map:["card"=["last4"="4242"], "amount"=1099]; + payments.info("charged", attributes); assert capture.records.size == 1; Record record = capture.records[0]; assert record.message == "charged"; - assert record.attrs["requestId"] == "r_slog"; - assert var paymentAttrs := record.attrs.get("payments"); + assert record.attributes["requestId"] == "r_slog"; + assert var paymentAttrs := record.attributes.get("payments"); assert paymentAttrs.is(Attributes); assert paymentAttrs["amount"] == 1099; assert var cardAttrs := paymentAttrs.get("card"); @@ -140,7 +140,7 @@ module TestSLogging { } assert capture.records.size == 1; - assert var value := capture.records[0].attrs.get("context"); + assert var value := capture.records[0].attributes.get("context"); assert value.is(String) && value == "bound"; }