From 72e9a94ec20202598734c0edc99e6f5cba3fd296 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 12 Jun 2026 18:27:09 -0700 Subject: [PATCH] feat(lib/log): scopes carry custom message metadata closes #45. Message.custom was already serialized by tee consumers and the progress streams, but nothing could set it. log.scoped takes an optional custom object attached to every message the scope emits; nested scopes merge their fields over inherited ones. --- lib/log.test.ts | 29 +++++++++++++++++++++++++++++ lib/log.ts | 17 ++++++++++++----- lib/readme.changes.md | 4 ++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/log.test.ts b/lib/log.test.ts index 50db951b698eed843b431b5e3216d18889534e74..be38397d8961e951e473ed264a6395d4c95959b6 100644 --- a/lib/log.test.ts +++ b/lib/log.test.ts @@ -1,6 +1,35 @@ // the namespace import is a valid Scope log satisfies log.Scope; +describe("log scopes", () => { + test("scope metadata attaches to messages", () => { + const messages: log.Message[] = []; + const root = log.headlessScope((m) => messages.push(m)); + + const req = root.scoped("http", { method: "GET", path: "/" }); + req.info("incoming"); + req.write("chunk"); + ASSERT(messages[0]?.custom?.method === "GET"); + ASSERT(messages[1]?.custom?.path === "/"); + + // nested scopes merge their fields over inherited ones + const child = req.scoped("auth", { method: "POST", user: "clo" }); + child.warn("denied"); + ASSERT(messages[2]?.custom?.method === "POST"); + ASSERT(messages[2]?.custom?.path === "/"); + ASSERT(messages[2]?.custom?.user === "clo"); + + // sibling scopes are unaffected by the child's overrides + req.scoped("other").info("hi"); + ASSERT(messages[3]?.custom?.method === "GET"); + ASSERT(messages[3]?.custom?.user === undefined); + + // scopes without metadata emit none + root.scoped("plain").info("hello"); + ASSERT(messages[4]?.custom === undefined); + }); +}); + // these tests for `startWidget` are built on a custom widget host // implementation that buffers all bytes in memory. it is a great // example of how modular the entire system is. diff --git a/lib/log.ts b/lib/log.ts index 8f173fef3670933d7668767acf59993ad54dbd0e..71b884eae08e4e1b2c6da2884e2dd2dde6de5ca6 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -80,7 +80,7 @@ export interface Scope { writeMessage(message: Message): void; /** create a nested sub-scope */ - scoped(name: string): Scope; + scoped(name: string, custom?: Message["custom"]): Scope; /** redirect the logging output of this scope somewhere else */ tee(writer: (message: Message) => void): ts.Dispose; } @@ -154,9 +154,9 @@ export function log(...args: unknown[]) { export function debug(...args: unknown[]) { globalLog.debug(...args); } -/** create a named logging scope */ -export function scoped(name: string): Scope { - return globalLog.scoped(name); +/** create a named logging scope, optionally carrying custom message fields */ +export function scoped(name: string, custom?: Message["custom"]): Scope { + return globalLog.scoped(name, custom); } /** redirect all log messages to another writer */ export function tee(destination: DispatchFunction): ts.Dispose { @@ -1058,6 +1058,8 @@ let withinStackCapture = false; /** this class is an implementation detail */ const ScopeImpl = class Scope implements RootScope { name: string | undefined; + /** attached to every emitted message; see `Scope.scoped` */ + #custom: Message["custom"]; // TODO: this abstraction implementation has low performance. making every // scope define it's own dispatch is needed to correctly implement `tee`. // since it is possible to implement this in simple and non-recursive way, i @@ -1068,8 +1070,10 @@ const ScopeImpl = class Scope implements RootScope { constructor( dispatch: (m: Message) => void, name: string | undefined = undefined, + custom: Message["custom"] = undefined, ) { this.name = name; + this.#custom = custom; this.#dispatches = [dispatch]; } @@ -1083,6 +1087,7 @@ const ScopeImpl = class Scope implements RootScope { this.writeMessage({ level, scope: this.name, + custom: this.#custom, get text() { const value = formatLine(...args); Object.defineProperty(this, "text", { value }); @@ -1127,6 +1132,7 @@ const ScopeImpl = class Scope implements RootScope { this.writeMessage({ level: "info", scope: "", + custom: this.#custom, newline: false, text, time: Date.now(), @@ -1134,11 +1140,12 @@ const ScopeImpl = class Scope implements RootScope { }); }; - scoped(name: string): Scope { + scoped(name: string, custom?: Message["custom"]): Scope { const current = this.name; return new Scope( this.#dispatch, name ? current ? `${current}/${name}` : name : current, + custom ? { ...this.#custom, ...custom } : this.#custom, ); } diff --git a/lib/readme.changes.md b/lib/readme.changes.md index 2619b097185e7c8b958e95ff5c92364082b71217..1e5bea473919187b0feed50602f839b289c41ea1 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -12,14 +12,18 @@ - `meta.toTags` creates an object compatible with TanStack Router - the `extra` object now takes an array of React-like elements among some other supported forms. +- `log` has recieved so many bug-fix changes that it is worth ensuring your + application edge cases still work as expected. ### features - `mime`'s database contains `.eot` for embedded opentype fonts. - `async.deferred` - `log` + - many many many many rendering bug-fixes - `writeError` writes to stderr with proper logic. - buffering auto-flushes synchronously when it grows past 64k. + - custom scopes ## v4 -- 2.54.0