authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-12 18:27:09-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-15 12:49:48-07:00
log72e9a94ec20202598734c0edc99e6f5cba3fd296
treefffad0117dc21aa0d8089a8e74bfcdd0926cae36
parentbd99670fb91ecff5cbdb1a33ecbf927cdb3e6556
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

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.

3 files changed, 45 insertions(+), 5 deletions(-)

lib/log.test.ts+29
......@@ -1,6 +1,35 @@
11// the namespace import is a valid Scope
22log satisfies log.Scope;
33
4describe("log scopes", () => {
5 test("scope metadata attaches to messages", () => {
6 const messages: log.Message[] = [];
7 const root = log.headlessScope((m) => messages.push(m));
8
9 const req = root.scoped("http", { method: "GET", path: "/" });
10 req.info("incoming");
11 req.write("chunk");
12 ASSERT(messages[0]?.custom?.method === "GET");
13 ASSERT(messages[1]?.custom?.path === "/");
14
15 // nested scopes merge their fields over inherited ones
16 const child = req.scoped("auth", { method: "POST", user: "clo" });
17 child.warn("denied");
18 ASSERT(messages[2]?.custom?.method === "POST");
19 ASSERT(messages[2]?.custom?.path === "/");
20 ASSERT(messages[2]?.custom?.user === "clo");
21
22 // sibling scopes are unaffected by the child's overrides
23 req.scoped("other").info("hi");
24 ASSERT(messages[3]?.custom?.method === "GET");
25 ASSERT(messages[3]?.custom?.user === undefined);
26
27 // scopes without metadata emit none
28 root.scoped("plain").info("hello");
29 ASSERT(messages[4]?.custom === undefined);
30 });
31});
32
433// these tests for `startWidget` are built on a custom widget host
534// implementation that buffers all bytes in memory. it is a great
635// example of how modular the entire system is.
lib/log.ts+12-5
......@@ -80,7 +80,7 @@ export interface Scope {
8080 writeMessage(message: Message): void;
8181
8282 /** create a nested sub-scope */
83 scoped(name: string): Scope;
83 scoped(name: string, custom?: Message["custom"]): Scope;
8484 /** redirect the logging output of this scope somewhere else */
8585 tee(writer: (message: Message) => void): ts.Dispose;
8686}
......@@ -154,9 +154,9 @@ export function log(...args: unknown[]) {
154154export function debug(...args: unknown[]) {
155155 globalLog.debug(...args);
156156}
157/** create a named logging scope */
158export function scoped(name: string): Scope {
159 return globalLog.scoped(name);
157/** create a named logging scope, optionally carrying custom message fields */
158export function scoped(name: string, custom?: Message["custom"]): Scope {
159 return globalLog.scoped(name, custom);
160160}
161161/** redirect all log messages to another writer */
162162export function tee(destination: DispatchFunction): ts.Dispose {
......@@ -1058,6 +1058,8 @@ let withinStackCapture = false;
10581058/** this class is an implementation detail */
10591059const ScopeImpl = class Scope implements RootScope {
10601060 name: string | undefined;
1061 /** attached to every emitted message; see `Scope.scoped` */
1062 #custom: Message["custom"];
10611063 // TODO: this abstraction implementation has low performance. making every
10621064 // scope define it's own dispatch is needed to correctly implement `tee`.
10631065 // since it is possible to implement this in simple and non-recursive way, i
......@@ -1068,8 +1070,10 @@ const ScopeImpl = class Scope implements RootScope {
10681070 constructor(
10691071 dispatch: (m: Message) => void,
10701072 name: string | undefined = undefined,
1073 custom: Message["custom"] = undefined,
10711074 ) {
10721075 this.name = name;
1076 this.#custom = custom;
10731077 this.#dispatches = [dispatch];
10741078 }
10751079
......@@ -1083,6 +1087,7 @@ const ScopeImpl = class Scope implements RootScope {
10831087 this.writeMessage({
10841088 level,
10851089 scope: this.name,
1090 custom: this.#custom,
10861091 get text() {
10871092 const value = formatLine(...args);
10881093 Object.defineProperty(this, "text", { value });
......@@ -1127,6 +1132,7 @@ const ScopeImpl = class Scope implements RootScope {
11271132 this.writeMessage({
11281133 level: "info",
11291134 scope: "",
1135 custom: this.#custom,
11301136 newline: false,
11311137 text,
11321138 time: Date.now(),
......@@ -1134,11 +1140,12 @@ const ScopeImpl = class Scope implements RootScope {
11341140 });
11351141 };
11361142
1137 scoped(name: string): Scope {
1143 scoped(name: string, custom?: Message["custom"]): Scope {
11381144 const current = this.name;
11391145 return new Scope(
11401146 this.#dispatch,
11411147 name ? current ? `${current}/${name}` : name : current,
1148 custom ? { ...this.#custom, ...custom } : this.#custom,
11421149 );
11431150 }
11441151
lib/readme.changes.md+4
......@@ -12,14 +12,18 @@
1212 - `meta.toTags` creates an object compatible with TanStack Router
1313 - the `extra` object now takes an array of React-like elements among some
1414 other supported forms.
15- `log` has recieved so many bug-fix changes that it is worth ensuring your
16 application edge cases still work as expected.
1517
1618### features
1719
1820- `mime`'s database contains `.eot` for embedded opentype fonts.
1921- `async.deferred`
2022- `log`
23 - many many many many rendering bug-fixes
2124 - `writeError` writes to stderr with proper logic.
2225 - buffering auto-flushes synchronously when it grows past 64k.
26 - custom scopes
2327
2428## v4
2529