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 @@...@@ -1,6 +1,35 @@
1// the namespace import is a valid Scope1// the namespace import is a valid Scope
2log satisfies log.Scope;2log 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
4// these tests for `startWidget` are built on a custom widget host33// these tests for `startWidget` are built on a custom widget host
5// implementation that buffers all bytes in memory. it is a great34// implementation that buffers all bytes in memory. it is a great
6// example of how modular the entire system is.35// example of how modular the entire system is.
lib/log.ts+12-5
...@@ -80,7 +80,7 @@ export interface Scope {...@@ -80,7 +80,7 @@ export interface Scope {
80 writeMessage(message: Message): void;80 writeMessage(message: Message): void;
8181
82 /** create a nested sub-scope */82 /** create a nested sub-scope */
83 scoped(name: string): Scope;83 scoped(name: string, custom?: Message["custom"]): Scope;
84 /** redirect the logging output of this scope somewhere else */84 /** redirect the logging output of this scope somewhere else */
85 tee(writer: (message: Message) => void): ts.Dispose;85 tee(writer: (message: Message) => void): ts.Dispose;
86}86}
...@@ -154,9 +154,9 @@ export function log(...args: unknown[]) {...@@ -154,9 +154,9 @@ export function log(...args: unknown[]) {
154export function debug(...args: unknown[]) {154export function debug(...args: unknown[]) {
155 globalLog.debug(...args);155 globalLog.debug(...args);
156}156}
157/** create a named logging scope */157/** create a named logging scope, optionally carrying custom message fields */
158export function scoped(name: string): Scope {158export function scoped(name: string, custom?: Message["custom"]): Scope {
159 return globalLog.scoped(name);159 return globalLog.scoped(name, custom);
160}160}
161/** redirect all log messages to another writer */161/** redirect all log messages to another writer */
162export function tee(destination: DispatchFunction): ts.Dispose {162export function tee(destination: DispatchFunction): ts.Dispose {
...@@ -1058,6 +1058,8 @@ let withinStackCapture = false;...@@ -1058,6 +1058,8 @@ let withinStackCapture = false;
1058/** this class is an implementation detail */1058/** this class is an implementation detail */
1059const ScopeImpl = class Scope implements RootScope {1059const ScopeImpl = class Scope implements RootScope {
1060 name: string | undefined;1060 name: string | undefined;
1061 /** attached to every emitted message; see `Scope.scoped` */
1062 #custom: Message["custom"];
1061 // TODO: this abstraction implementation has low performance. making every1063 // TODO: this abstraction implementation has low performance. making every
1062 // scope define it's own dispatch is needed to correctly implement `tee`.1064 // scope define it's own dispatch is needed to correctly implement `tee`.
1063 // since it is possible to implement this in simple and non-recursive way, i1065 // since it is possible to implement this in simple and non-recursive way, i
...@@ -1068,8 +1070,10 @@ const ScopeImpl = class Scope implements RootScope {...@@ -1068,8 +1070,10 @@ const ScopeImpl = class Scope implements RootScope {
1068 constructor(1070 constructor(
1069 dispatch: (m: Message) => void,1071 dispatch: (m: Message) => void,
1070 name: string | undefined = undefined,1072 name: string | undefined = undefined,
1073 custom: Message["custom"] = undefined,
1071 ) {1074 ) {
1072 this.name = name;1075 this.name = name;
1076 this.#custom = custom;
1073 this.#dispatches = [dispatch];1077 this.#dispatches = [dispatch];
1074 }1078 }
10751079
...@@ -1083,6 +1087,7 @@ const ScopeImpl = class Scope implements RootScope {...@@ -1083,6 +1087,7 @@ const ScopeImpl = class Scope implements RootScope {
1083 this.writeMessage({1087 this.writeMessage({
1084 level,1088 level,
1085 scope: this.name,1089 scope: this.name,
1090 custom: this.#custom,
1086 get text() {1091 get text() {
1087 const value = formatLine(...args);1092 const value = formatLine(...args);
1088 Object.defineProperty(this, "text", { value });1093 Object.defineProperty(this, "text", { value });
...@@ -1127,6 +1132,7 @@ const ScopeImpl = class Scope implements RootScope {...@@ -1127,6 +1132,7 @@ const ScopeImpl = class Scope implements RootScope {
1127 this.writeMessage({1132 this.writeMessage({
1128 level: "info",1133 level: "info",
1129 scope: "",1134 scope: "",
1135 custom: this.#custom,
1130 newline: false,1136 newline: false,
1131 text,1137 text,
1132 time: Date.now(),1138 time: Date.now(),
...@@ -1134,11 +1140,12 @@ const ScopeImpl = class Scope implements RootScope {...@@ -1134,11 +1140,12 @@ const ScopeImpl = class Scope implements RootScope {
1134 });1140 });
1135 };1141 };
11361142
1137 scoped(name: string): Scope {1143 scoped(name: string, custom?: Message["custom"]): Scope {
1138 const current = this.name;1144 const current = this.name;
1139 return new Scope(1145 return new Scope(
1140 this.#dispatch,1146 this.#dispatch,
1141 name ? current ? `${current}/${name}` : name : current,1147 name ? current ? `${current}/${name}` : name : current,
1148 custom ? { ...this.#custom, ...custom } : this.#custom,
1142 );1149 );
1143 }1150 }
11441151
lib/readme.changes.md+4
...@@ -12,14 +12,18 @@...@@ -12,14 +12,18 @@
12 - `meta.toTags` creates an object compatible with TanStack Router12 - `meta.toTags` creates an object compatible with TanStack Router
13 - the `extra` object now takes an array of React-like elements among some13 - the `extra` object now takes an array of React-like elements among some
14 other supported forms.14 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
16### features18### features
1719
18- `mime`'s database contains `.eot` for embedded opentype fonts.20- `mime`'s database contains `.eot` for embedded opentype fonts.
19- `async.deferred`21- `async.deferred`
20- `log`22- `log`
23 - many many many many rendering bug-fixes
21 - `writeError` writes to stderr with proper logic.24 - `writeError` writes to stderr with proper logic.
22 - buffering auto-flushes synchronously when it grows past 64k.25 - buffering auto-flushes synchronously when it grows past 64k.
26 - custom scopes
2327
24## v428## v4
2529