@decohere/scribe 0.0.1-dev.119 → 0.0.1-dev.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,13 +28,16 @@ class ConsoleAdapter extends _loggeradapter.LogAdapter {
28
28
  }
29
29
  getLogger(name) {
30
30
  const loggerName = name || "default";
31
- return this.createConsoleLogger(loggerName);
31
+ return this.createConsoleLogger(loggerName, {});
32
32
  }
33
- createConsoleLogger(name) {
33
+ createConsoleLogger(name, bindings) {
34
34
  const allLevels = this.configuredLevels;
35
35
  const currentLevel = this.configuredLogLevel;
36
36
  const threshold = this.configuredLogLevelValue;
37
37
  const redactionKeys = this.redactionKeys;
38
+ const bound = Object.keys(bindings).length > 0 ? [
39
+ _redact.redact.object(bindings, redactionKeys)
40
+ ] : [];
38
41
  const baseLogger = {
39
42
  get currentLevel () {
40
43
  return currentLevel;
@@ -52,7 +55,10 @@ class ConsoleAdapter extends _loggeradapter.LogAdapter {
52
55
  message = String(msg);
53
56
  }
54
57
  const logLine = `[${timestamp}] ${level.toUpperCase()} [${name}]: ${message}`;
55
- const payload = args.map((arg)=>_redact.redact.object(arg, redactionKeys));
58
+ const payload = [
59
+ ...args.map((arg)=>_redact.redact.object(arg, redactionKeys)),
60
+ ...bound
61
+ ];
56
62
  if (payload.length > 0) {
57
63
  console.log(logLine, ...payload);
58
64
  } else {
@@ -61,7 +67,11 @@ class ConsoleAdapter extends _loggeradapter.LogAdapter {
61
67
  };
62
68
  const noop = ()=>{};
63
69
  const logger = Object.assign(baseLogger, {
64
- isLevelEnabled: (level)=>(allLevels[level] ?? 0) >= threshold
70
+ isLevelEnabled: (level)=>(allLevels[level] ?? 0) >= threshold,
71
+ child: (fields)=>this.createConsoleLogger(name, {
72
+ ...bindings,
73
+ ...fields
74
+ })
65
75
  });
66
76
  for (const [levelName, levelValue] of Object.entries(allLevels)){
67
77
  logger[levelName] = levelValue >= threshold ? logFunction(levelName) : noop;
@@ -39,8 +39,9 @@ const VALID_LEVELS = [
39
39
  "trace"
40
40
  ];
41
41
  function getLogLevelFromEnv() {
42
+ const env = globalThis.process?.env;
42
43
  for (const name of ENV_VAR_NAMES){
43
- const value = process.env[name]?.trim().toLowerCase();
44
+ const value = env?.[name]?.trim().toLowerCase();
44
45
  if (value) {
45
46
  return value;
46
47
  }
@@ -5,6 +5,8 @@ Object.defineProperty(exports, "__esModule", {
5
5
  _export_star(require("./console.adapter.js"), exports);
6
6
  _export_star(require("./env.js"), exports);
7
7
  _export_star(require("./errors.js"), exports);
8
+ _export_star(require("./log-entry.adapter.js"), exports);
9
+ _export_star(require("./log-entry.js"), exports);
8
10
  _export_star(require("./logger.adapter.js"), exports);
9
11
  _export_star(require("./logger.factory.js"), exports);
10
12
  _export_star(require("./logger.type.js"), exports);
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "LogEntryAdapter", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return LogEntryAdapter;
9
+ }
10
+ });
11
+ const _logentry = require("./log-entry.js");
12
+ const _loggeradapter = require("./logger.adapter.js");
13
+ const _redact = require("./redact.js");
14
+ const _types = require("./types.js");
15
+ class LogEntryAdapter extends _loggeradapter.LogAdapter {
16
+ levels = _types.LogLevels;
17
+ level = _types.DefaultLogLevel;
18
+ threshold = _types.LogLevels.info;
19
+ redactionKeys = _redact.redact.fromOptions();
20
+ configure(scribeOptions) {
21
+ this.levels = this.getLogLevels(scribeOptions);
22
+ this.level = scribeOptions.logLevel ?? _types.DefaultLogLevel;
23
+ this.threshold = this.levels[this.level] ?? _types.LogLevels.info;
24
+ this.redactionKeys = _redact.redact.fromOptions(scribeOptions.redactions);
25
+ }
26
+ getLogger(name) {
27
+ return this.createLogger(name || "default", {});
28
+ }
29
+ createLogger(name, bindings) {
30
+ const { levels, level: currentLevel, threshold, redactionKeys } = this;
31
+ const baseLogger = {
32
+ get currentLevel () {
33
+ return currentLevel;
34
+ },
35
+ get logLevels () {
36
+ return levels;
37
+ }
38
+ };
39
+ const noop = ()=>{};
40
+ const methods = {};
41
+ for (const [level, value] of Object.entries(levels)){
42
+ methods[level] = value >= threshold ? (...args)=>this.write(_logentry.logEntry.from(level, name, args, {
43
+ bindings,
44
+ redactionKeys
45
+ })) : noop;
46
+ }
47
+ return Object.assign(baseLogger, methods, {
48
+ isLevelEnabled: (level)=>(levels[level] ?? 0) >= threshold,
49
+ child: (fields)=>this.createLogger(name, {
50
+ ...bindings,
51
+ ...fields
52
+ })
53
+ });
54
+ }
55
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "logEntry", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return logEntry;
9
+ }
10
+ });
11
+ const _loggeradapter = require("./logger.adapter.js");
12
+ const _redact = require("./redact.js");
13
+ function isFields(value) {
14
+ return value != null && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+ /** Serialize errors passed as fields (`{ error }`), so they keep their message and stack. */ function serializeErrors(fields) {
17
+ const out = {};
18
+ for (const [key, value] of Object.entries(fields)){
19
+ out[key] = value instanceof Error ? (0, _loggeradapter.serializeError)(value) : value;
20
+ }
21
+ return out;
22
+ }
23
+ /**
24
+ * Builds an entry from a log call's arguments:
25
+ *
26
+ * - `log.info("text")`, `log.info(42)` → `msg`
27
+ * - `log.error(err)` → `msg` is `err.message`, `err` the serialized error
28
+ * - `log.info({ fields })`, `log.info({ fields }, "text")` → `fields` (a string
29
+ * `msg` field becomes the message)
30
+ * - `log.info("text", { fields })` → `msg` + `fields`
31
+ * - `log.error("text", err)` → `msg` + `err`
32
+ * - `log.info("text", value)` → `msg` + `fields.data`; any further arguments go
33
+ * in `fields.args`
34
+ */ function from(level, name, [first, ...rest], { bindings, redactionKeys }) {
35
+ let msg;
36
+ let err;
37
+ let fields = {};
38
+ if (first instanceof Error) {
39
+ err = first;
40
+ msg = first.message;
41
+ } else if (isFields(first)) {
42
+ const { msg: fieldMsg, ...others } = first;
43
+ fields = others;
44
+ if (typeof fieldMsg === "string") {
45
+ msg = fieldMsg;
46
+ } else if (fieldMsg !== undefined) {
47
+ fields.msg = fieldMsg;
48
+ }
49
+ if (typeof rest[0] === "string") {
50
+ msg = rest.shift();
51
+ }
52
+ } else if (first !== undefined) {
53
+ msg = String(first);
54
+ }
55
+ const [second, ...extra] = rest;
56
+ if (second instanceof Error && err == null) {
57
+ err = second;
58
+ } else if (isFields(second)) {
59
+ fields = {
60
+ ...fields,
61
+ ...second
62
+ };
63
+ } else if (second !== undefined) {
64
+ fields.data = second;
65
+ }
66
+ if (extra.length > 0) {
67
+ fields.args = extra;
68
+ }
69
+ const allFields = serializeErrors({
70
+ ...bindings,
71
+ ...fields
72
+ });
73
+ const entry = {
74
+ level,
75
+ name,
76
+ fields: redactionKeys == null ? allFields : _redact.redact.object(allFields, redactionKeys)
77
+ };
78
+ if (msg !== undefined) {
79
+ entry.msg = msg;
80
+ }
81
+ if (err != null) {
82
+ const serialized = (0, _loggeradapter.serializeError)(err);
83
+ entry.err = redactionKeys == null ? serialized : _redact.redact.object(serialized, redactionKeys);
84
+ }
85
+ return entry;
86
+ }
87
+ const logEntry = {
88
+ from
89
+ };
@@ -9,17 +9,52 @@ function _export(target, all) {
9
9
  });
10
10
  }
11
11
  _export(exports, {
12
+ get DefaultErrorCauseDepth () {
13
+ return DefaultErrorCauseDepth;
14
+ },
12
15
  get LogAdapter () {
13
16
  return LogAdapter;
14
17
  },
15
18
  get formatError () {
16
19
  return formatError;
20
+ },
21
+ get serializeError () {
22
+ return serializeError;
17
23
  }
18
24
  });
19
25
  const _types = require("./types.js");
20
26
  function formatError(err) {
21
27
  return err.stack ?? err.message;
22
28
  }
29
+ const DefaultErrorCauseDepth = 3;
30
+ function serializeError(err, causeDepth = DefaultErrorCauseDepth) {
31
+ const out = {
32
+ name: err.name,
33
+ message: err.message
34
+ };
35
+ if (err.stack != null) {
36
+ out.stack = err.stack;
37
+ }
38
+ for (const [key, value] of Object.entries(err)){
39
+ if (key === "cause" || key in out) {
40
+ continue;
41
+ }
42
+ if (!(value instanceof Error)) {
43
+ out[key] = value;
44
+ } else if (causeDepth > 0) {
45
+ out[key] = serializeError(value, causeDepth - 1);
46
+ }
47
+ }
48
+ const { cause } = err;
49
+ if (cause instanceof Error) {
50
+ if (causeDepth > 0) {
51
+ out.cause = serializeError(cause, causeDepth - 1);
52
+ }
53
+ } else if (cause !== undefined) {
54
+ out.cause = cause;
55
+ }
56
+ return out;
57
+ }
23
58
  class LogAdapter {
24
59
  flush(cb) {
25
60
  cb?.();
@@ -11,11 +11,13 @@ Object.defineProperty(exports, "scribe", {
11
11
  const _consoleadapter = require("./console.adapter.js");
12
12
  const _env = require("./env.js");
13
13
  const _errors = require("./errors.js");
14
+ const _logentry = require("./log-entry.js");
14
15
  const _types = require("./types.js");
15
16
  class scribe {
16
17
  loggerProvider;
17
18
  /** True after `configure({ adapter })` — the constructor’s ConsoleAdapter is only a fallback. */ adapterConfigured = false;
18
19
  defaultAdapterWarned = false;
20
+ tap;
19
21
  static instance = new scribe();
20
22
  static logger(name, customLevels) {
21
23
  // Apply custom levels once against the active adapter; the live handle
@@ -48,17 +50,39 @@ class scribe {
48
50
  /**
49
51
  * Module-level `const log = scribe.logger(...)` must follow a later
50
52
  * `scribe.configure()` (CLI / Flux / HotReloadServer). Forward every use to
51
- * the active adapter logger for `name`.
52
- */ static createLiveLogger(name) {
53
+ * the active adapter logger for `name`, and `child(fields)` to a live handle
54
+ * that adds `bindings`, so children follow a reconfigure too.
55
+ */ static createLiveLogger(name, bindings = {}) {
56
+ const bound = Object.keys(bindings).length > 0;
57
+ const resolve = ()=>{
58
+ const root = scribe.instance.getLogger(name);
59
+ return bound ? root.child(bindings) : root;
60
+ };
53
61
  return new Proxy({}, {
54
62
  get (_target, prop) {
55
- const underlying = scribe.instance.getLogger(name);
63
+ if (prop === "child") {
64
+ return (fields)=>scribe.createLiveLogger(name, {
65
+ ...bindings,
66
+ ...fields
67
+ });
68
+ }
69
+ const underlying = resolve();
56
70
  const value = Reflect.get(underlying, prop, underlying);
57
- return typeof value === "function" ? value.bind(underlying) : value;
71
+ if (typeof value !== "function") {
72
+ return value;
73
+ }
74
+ const method = value.bind(underlying);
75
+ const instance = scribe.instance;
76
+ if (instance.tap == null || typeof prop !== "string" || !Object.hasOwn(underlying.logLevels, prop) || !underlying.isLevelEnabled(prop)) {
77
+ return method;
78
+ }
79
+ return (...args)=>{
80
+ method(...args);
81
+ instance.notifyTap(prop, name ?? "default", args, bindings);
82
+ };
58
83
  },
59
84
  has (_target, prop) {
60
- const underlying = scribe.instance.getLogger(name);
61
- return Reflect.has(underlying, prop);
85
+ return Reflect.has(resolve(), prop);
62
86
  }
63
87
  });
64
88
  }
@@ -69,13 +93,14 @@ class scribe {
69
93
  this.loggerProvider.configure(defaultOptions);
70
94
  }
71
95
  configure(configOptions) {
72
- const { adapter, options } = configOptions;
96
+ const { adapter, options, tap } = configOptions;
73
97
  const previous = this.loggerProvider;
74
98
  if (previous !== adapter) {
75
99
  previous.retire();
76
100
  }
77
101
  this.loggerProvider = adapter;
78
102
  this.adapterConfigured = true;
103
+ this.tap = tap;
79
104
  const defaultsAndOptions = this.mergeDefaults(options);
80
105
  adapter.configure(defaultsAndOptions);
81
106
  if (previous !== adapter) {
@@ -105,12 +130,29 @@ class scribe {
105
130
  this.supportsLevels(customLevels, logger);
106
131
  return logger;
107
132
  }
133
+ notifyTap(level, name, args, bindings) {
134
+ const tap = this.tap;
135
+ if (tap == null) {
136
+ return;
137
+ }
138
+ try {
139
+ tap(_logentry.logEntry.from(level, name, args, {
140
+ bindings
141
+ }));
142
+ } catch (err) {
143
+ // Not swallowed: the logging call returns, the runtime reports the tap's error.
144
+ queueMicrotask(()=>{
145
+ throw err;
146
+ });
147
+ }
148
+ }
108
149
  warnIfDefaultAdapter() {
109
150
  if (this.adapterConfigured || this.defaultAdapterWarned) {
110
151
  return;
111
152
  }
112
153
  this.defaultAdapterWarned = true;
113
- process.stderr.write("[scribe] No logger adapter configured; using the default ConsoleAdapter. " + "Configure an adapter at your composition root (for production, prefer " + "@decohere/scribe-pino → JSON on stdout). See " + "https://github.com/typesmiths/decohere/blob/main/projects/scribe/README.md\n");
154
+ // console.warn, not the stderr stream: the facade runs in browsers and Workers too.
155
+ console.warn("[scribe] No logger adapter configured; using the default ConsoleAdapter. " + "Configure an adapter at your composition root (for production, prefer " + "@decohere/scribe-pino → JSON on stdout). See " + "https://github.com/typesmiths/decohere/blob/main/projects/scribe/README.md");
114
156
  }
115
157
  supportsLevels(customLevels, logger) {
116
158
  if (!customLevels) {
@@ -27,26 +27,33 @@ function isPlainObject(value) {
27
27
  const proto = Object.getPrototypeOf(value);
28
28
  return proto === Object.prototype || proto === null;
29
29
  }
30
- function object(value, keys, censor = _types.DefaultCensor, seen = new WeakSet()) {
30
+ function object(value, keys, censor = _types.DefaultCensor, copies = new WeakMap()) {
31
31
  if (value == null || typeof value !== "object") {
32
32
  return value;
33
33
  }
34
34
  if (value instanceof Error) {
35
35
  return value;
36
36
  }
37
- if (seen.has(value)) {
38
- return value;
37
+ // A cycle in the input stays a cycle in the copy.
38
+ const copied = copies.get(value);
39
+ if (copied !== undefined) {
40
+ return copied;
39
41
  }
40
- seen.add(value);
41
42
  if (Array.isArray(value)) {
42
- return value.map((item)=>object(item, keys, censor, seen));
43
+ const out = [];
44
+ copies.set(value, out);
45
+ for (const item of value){
46
+ out.push(object(item, keys, censor, copies));
47
+ }
48
+ return out;
43
49
  }
44
50
  if (!isPlainObject(value)) {
45
51
  return value;
46
52
  }
47
53
  const out = {};
54
+ copies.set(value, out);
48
55
  for (const [key, nested] of Object.entries(value)){
49
- out[key] = keys.has(key) ? censor : object(nested, keys, censor, seen);
56
+ out[key] = keys.has(key) ? censor : object(nested, keys, censor, copies);
50
57
  }
51
58
  return out;
52
59
  }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "CaptureAdapter", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return CaptureAdapter;
9
+ }
10
+ });
11
+ const _logentryadapter = require("../log-entry.adapter.js");
12
+ class CaptureAdapter extends _logentryadapter.LogEntryAdapter {
13
+ entries = [];
14
+ /** Entries at `level`. */ at(level) {
15
+ return this.entries.filter((entry)=>entry.level === level);
16
+ }
17
+ clear() {
18
+ this.entries.length = 0;
19
+ }
20
+ write(entry) {
21
+ this.entries.push(entry);
22
+ }
23
+ }
@@ -2,3 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", {
3
3
  value: true
4
4
  });
5
+ _export_star(require("./capture.adapter.js"), exports);
6
+ function _export_star(from, to) {
7
+ Object.keys(from).forEach(function(k) {
8
+ if (k !== "default" && !Object.prototype.hasOwnProperty.call(to, k)) {
9
+ Object.defineProperty(to, k, {
10
+ enumerable: true,
11
+ get: function() {
12
+ return from[k];
13
+ }
14
+ });
15
+ }
16
+ });
17
+ return from;
18
+ }
@@ -18,13 +18,16 @@ export class ConsoleAdapter extends LogAdapter {
18
18
  }
19
19
  getLogger(name) {
20
20
  const loggerName = name || "default";
21
- return this.createConsoleLogger(loggerName);
21
+ return this.createConsoleLogger(loggerName, {});
22
22
  }
23
- createConsoleLogger(name) {
23
+ createConsoleLogger(name, bindings) {
24
24
  const allLevels = this.configuredLevels;
25
25
  const currentLevel = this.configuredLogLevel;
26
26
  const threshold = this.configuredLogLevelValue;
27
27
  const redactionKeys = this.redactionKeys;
28
+ const bound = Object.keys(bindings).length > 0 ? [
29
+ redact.object(bindings, redactionKeys)
30
+ ] : [];
28
31
  const baseLogger = {
29
32
  get currentLevel () {
30
33
  return currentLevel;
@@ -42,7 +45,10 @@ export class ConsoleAdapter extends LogAdapter {
42
45
  message = String(msg);
43
46
  }
44
47
  const logLine = `[${timestamp}] ${level.toUpperCase()} [${name}]: ${message}`;
45
- const payload = args.map((arg)=>redact.object(arg, redactionKeys));
48
+ const payload = [
49
+ ...args.map((arg)=>redact.object(arg, redactionKeys)),
50
+ ...bound
51
+ ];
46
52
  if (payload.length > 0) {
47
53
  console.log(logLine, ...payload);
48
54
  } else {
@@ -51,7 +57,11 @@ export class ConsoleAdapter extends LogAdapter {
51
57
  };
52
58
  const noop = ()=>{};
53
59
  const logger = Object.assign(baseLogger, {
54
- isLevelEnabled: (level)=>(allLevels[level] ?? 0) >= threshold
60
+ isLevelEnabled: (level)=>(allLevels[level] ?? 0) >= threshold,
61
+ child: (fields)=>this.createConsoleLogger(name, {
62
+ ...bindings,
63
+ ...fields
64
+ })
55
65
  });
56
66
  for (const [levelName, levelValue] of Object.entries(allLevels)){
57
67
  logger[levelName] = levelValue >= threshold ? logFunction(levelName) : noop;
package/es6/src/env.js CHANGED
@@ -17,10 +17,11 @@ const VALID_LEVELS = [
17
17
  /**
18
18
  * Reads the log level from environment variables.
19
19
  * Checks LOG_LEVEL, SCRIBE_LOG_LEVEL, and LOGGING_LEVEL in order.
20
- * Returns null if none are set.
20
+ * Returns null if none are set, or if the runtime has no `process` (browsers).
21
21
  */ export function getLogLevelFromEnv() {
22
+ const env = globalThis.process?.env;
22
23
  for (const name of ENV_VAR_NAMES){
23
- const value = process.env[name]?.trim().toLowerCase();
24
+ const value = env?.[name]?.trim().toLowerCase();
24
25
  if (value) {
25
26
  return value;
26
27
  }
package/es6/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./console.adapter.js";
2
2
  export * from "./env.js";
3
3
  export * from "./errors.js";
4
+ export * from "./log-entry.adapter.js";
5
+ export * from "./log-entry.js";
4
6
  export * from "./logger.adapter.js";
5
7
  export * from "./logger.factory.js";
6
8
  export * from "./logger.type.js";
@@ -0,0 +1,49 @@
1
+ import { logEntry } from "./log-entry.js";
2
+ import { LogAdapter } from "./logger.adapter.js";
3
+ import { redact } from "./redact.js";
4
+ import { DefaultLogLevel, LogLevels } from "./types.js";
5
+ /**
6
+ * Base for adapters that write structured entries. It owns levels, the level
7
+ * threshold, `child`, and censoring the `redactions` keys; a subclass only
8
+ * writes each `LogEntry`.
9
+ */ export class LogEntryAdapter extends LogAdapter {
10
+ levels = LogLevels;
11
+ level = DefaultLogLevel;
12
+ threshold = LogLevels.info;
13
+ redactionKeys = redact.fromOptions();
14
+ configure(scribeOptions) {
15
+ this.levels = this.getLogLevels(scribeOptions);
16
+ this.level = scribeOptions.logLevel ?? DefaultLogLevel;
17
+ this.threshold = this.levels[this.level] ?? LogLevels.info;
18
+ this.redactionKeys = redact.fromOptions(scribeOptions.redactions);
19
+ }
20
+ getLogger(name) {
21
+ return this.createLogger(name || "default", {});
22
+ }
23
+ createLogger(name, bindings) {
24
+ const { levels, level: currentLevel, threshold, redactionKeys } = this;
25
+ const baseLogger = {
26
+ get currentLevel () {
27
+ return currentLevel;
28
+ },
29
+ get logLevels () {
30
+ return levels;
31
+ }
32
+ };
33
+ const noop = ()=>{};
34
+ const methods = {};
35
+ for (const [level, value] of Object.entries(levels)){
36
+ methods[level] = value >= threshold ? (...args)=>this.write(logEntry.from(level, name, args, {
37
+ bindings,
38
+ redactionKeys
39
+ })) : noop;
40
+ }
41
+ return Object.assign(baseLogger, methods, {
42
+ isLevelEnabled: (level)=>(levels[level] ?? 0) >= threshold,
43
+ child: (fields)=>this.createLogger(name, {
44
+ ...bindings,
45
+ ...fields
46
+ })
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,79 @@
1
+ import { serializeError } from "./logger.adapter.js";
2
+ import { redact } from "./redact.js";
3
+ function isFields(value) {
4
+ return value != null && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+ /** Serialize errors passed as fields (`{ error }`), so they keep their message and stack. */ function serializeErrors(fields) {
7
+ const out = {};
8
+ for (const [key, value] of Object.entries(fields)){
9
+ out[key] = value instanceof Error ? serializeError(value) : value;
10
+ }
11
+ return out;
12
+ }
13
+ /**
14
+ * Builds an entry from a log call's arguments:
15
+ *
16
+ * - `log.info("text")`, `log.info(42)` → `msg`
17
+ * - `log.error(err)` → `msg` is `err.message`, `err` the serialized error
18
+ * - `log.info({ fields })`, `log.info({ fields }, "text")` → `fields` (a string
19
+ * `msg` field becomes the message)
20
+ * - `log.info("text", { fields })` → `msg` + `fields`
21
+ * - `log.error("text", err)` → `msg` + `err`
22
+ * - `log.info("text", value)` → `msg` + `fields.data`; any further arguments go
23
+ * in `fields.args`
24
+ */ function from(level, name, [first, ...rest], { bindings, redactionKeys }) {
25
+ let msg;
26
+ let err;
27
+ let fields = {};
28
+ if (first instanceof Error) {
29
+ err = first;
30
+ msg = first.message;
31
+ } else if (isFields(first)) {
32
+ const { msg: fieldMsg, ...others } = first;
33
+ fields = others;
34
+ if (typeof fieldMsg === "string") {
35
+ msg = fieldMsg;
36
+ } else if (fieldMsg !== undefined) {
37
+ fields.msg = fieldMsg;
38
+ }
39
+ if (typeof rest[0] === "string") {
40
+ msg = rest.shift();
41
+ }
42
+ } else if (first !== undefined) {
43
+ msg = String(first);
44
+ }
45
+ const [second, ...extra] = rest;
46
+ if (second instanceof Error && err == null) {
47
+ err = second;
48
+ } else if (isFields(second)) {
49
+ fields = {
50
+ ...fields,
51
+ ...second
52
+ };
53
+ } else if (second !== undefined) {
54
+ fields.data = second;
55
+ }
56
+ if (extra.length > 0) {
57
+ fields.args = extra;
58
+ }
59
+ const allFields = serializeErrors({
60
+ ...bindings,
61
+ ...fields
62
+ });
63
+ const entry = {
64
+ level,
65
+ name,
66
+ fields: redactionKeys == null ? allFields : redact.object(allFields, redactionKeys)
67
+ };
68
+ if (msg !== undefined) {
69
+ entry.msg = msg;
70
+ }
71
+ if (err != null) {
72
+ const serialized = serializeError(err);
73
+ entry.err = redactionKeys == null ? serialized : redact.object(serialized, redactionKeys);
74
+ }
75
+ return entry;
76
+ }
77
+ export const logEntry = {
78
+ from
79
+ };
@@ -2,6 +2,39 @@ import { LogLevels } from "./types.js";
2
2
  export function formatError(err) {
3
3
  return err.stack ?? err.message;
4
4
  }
5
+ /** How many causes deep `serializeError` follows by default. */ export const DefaultErrorCauseDepth = 3;
6
+ /**
7
+ * Plain-object form of an error for structured logs. An `Error` cause, or an
8
+ * `Error` in another own property, is serialized the same way, up to
9
+ * `causeDepth` levels; any other value is kept as it is.
10
+ */ export function serializeError(err, causeDepth = DefaultErrorCauseDepth) {
11
+ const out = {
12
+ name: err.name,
13
+ message: err.message
14
+ };
15
+ if (err.stack != null) {
16
+ out.stack = err.stack;
17
+ }
18
+ for (const [key, value] of Object.entries(err)){
19
+ if (key === "cause" || key in out) {
20
+ continue;
21
+ }
22
+ if (!(value instanceof Error)) {
23
+ out[key] = value;
24
+ } else if (causeDepth > 0) {
25
+ out[key] = serializeError(value, causeDepth - 1);
26
+ }
27
+ }
28
+ const { cause } = err;
29
+ if (cause instanceof Error) {
30
+ if (causeDepth > 0) {
31
+ out.cause = serializeError(cause, causeDepth - 1);
32
+ }
33
+ } else if (cause !== undefined) {
34
+ out.cause = cause;
35
+ }
36
+ return out;
37
+ }
5
38
  export class LogAdapter {
6
39
  flush(cb) {
7
40
  cb?.();
@@ -1,11 +1,13 @@
1
1
  import { ConsoleAdapter } from "./console.adapter.js";
2
2
  import { resolveLogLevel } from "./env.js";
3
3
  import { LoggingConfigurationError } from "./errors.js";
4
+ import { logEntry } from "./log-entry.js";
4
5
  import { DefaultLogLevel, DefaultRedactions } from "./types.js";
5
6
  export class scribe {
6
7
  loggerProvider;
7
8
  /** True after `configure({ adapter })` — the constructor’s ConsoleAdapter is only a fallback. */ adapterConfigured = false;
8
9
  defaultAdapterWarned = false;
10
+ tap;
9
11
  static instance = new scribe();
10
12
  static logger(name, customLevels) {
11
13
  // Apply custom levels once against the active adapter; the live handle
@@ -38,17 +40,39 @@ export class scribe {
38
40
  /**
39
41
  * Module-level `const log = scribe.logger(...)` must follow a later
40
42
  * `scribe.configure()` (CLI / Flux / HotReloadServer). Forward every use to
41
- * the active adapter logger for `name`.
42
- */ static createLiveLogger(name) {
43
+ * the active adapter logger for `name`, and `child(fields)` to a live handle
44
+ * that adds `bindings`, so children follow a reconfigure too.
45
+ */ static createLiveLogger(name, bindings = {}) {
46
+ const bound = Object.keys(bindings).length > 0;
47
+ const resolve = ()=>{
48
+ const root = scribe.instance.getLogger(name);
49
+ return bound ? root.child(bindings) : root;
50
+ };
43
51
  return new Proxy({}, {
44
52
  get (_target, prop) {
45
- const underlying = scribe.instance.getLogger(name);
53
+ if (prop === "child") {
54
+ return (fields)=>scribe.createLiveLogger(name, {
55
+ ...bindings,
56
+ ...fields
57
+ });
58
+ }
59
+ const underlying = resolve();
46
60
  const value = Reflect.get(underlying, prop, underlying);
47
- return typeof value === "function" ? value.bind(underlying) : value;
61
+ if (typeof value !== "function") {
62
+ return value;
63
+ }
64
+ const method = value.bind(underlying);
65
+ const instance = scribe.instance;
66
+ if (instance.tap == null || typeof prop !== "string" || !Object.hasOwn(underlying.logLevels, prop) || !underlying.isLevelEnabled(prop)) {
67
+ return method;
68
+ }
69
+ return (...args)=>{
70
+ method(...args);
71
+ instance.notifyTap(prop, name ?? "default", args, bindings);
72
+ };
48
73
  },
49
74
  has (_target, prop) {
50
- const underlying = scribe.instance.getLogger(name);
51
- return Reflect.has(underlying, prop);
75
+ return Reflect.has(resolve(), prop);
52
76
  }
53
77
  });
54
78
  }
@@ -59,13 +83,14 @@ export class scribe {
59
83
  this.loggerProvider.configure(defaultOptions);
60
84
  }
61
85
  configure(configOptions) {
62
- const { adapter, options } = configOptions;
86
+ const { adapter, options, tap } = configOptions;
63
87
  const previous = this.loggerProvider;
64
88
  if (previous !== adapter) {
65
89
  previous.retire();
66
90
  }
67
91
  this.loggerProvider = adapter;
68
92
  this.adapterConfigured = true;
93
+ this.tap = tap;
69
94
  const defaultsAndOptions = this.mergeDefaults(options);
70
95
  adapter.configure(defaultsAndOptions);
71
96
  if (previous !== adapter) {
@@ -95,12 +120,29 @@ export class scribe {
95
120
  this.supportsLevels(customLevels, logger);
96
121
  return logger;
97
122
  }
123
+ notifyTap(level, name, args, bindings) {
124
+ const tap = this.tap;
125
+ if (tap == null) {
126
+ return;
127
+ }
128
+ try {
129
+ tap(logEntry.from(level, name, args, {
130
+ bindings
131
+ }));
132
+ } catch (err) {
133
+ // Not swallowed: the logging call returns, the runtime reports the tap's error.
134
+ queueMicrotask(()=>{
135
+ throw err;
136
+ });
137
+ }
138
+ }
98
139
  warnIfDefaultAdapter() {
99
140
  if (this.adapterConfigured || this.defaultAdapterWarned) {
100
141
  return;
101
142
  }
102
143
  this.defaultAdapterWarned = true;
103
- process.stderr.write("[scribe] No logger adapter configured; using the default ConsoleAdapter. " + "Configure an adapter at your composition root (for production, prefer " + "@decohere/scribe-pino → JSON on stdout). See " + "https://github.com/typesmiths/decohere/blob/main/projects/scribe/README.md\n");
144
+ // console.warn, not the stderr stream: the facade runs in browsers and Workers too.
145
+ console.warn("[scribe] No logger adapter configured; using the default ConsoleAdapter. " + "Configure an adapter at your composition root (for production, prefer " + "@decohere/scribe-pino → JSON on stdout). See " + "https://github.com/typesmiths/decohere/blob/main/projects/scribe/README.md");
104
146
  }
105
147
  supportsLevels(customLevels, logger) {
106
148
  if (!customLevels) {
package/es6/src/redact.js CHANGED
@@ -17,26 +17,33 @@ function isPlainObject(value) {
17
17
  const proto = Object.getPrototypeOf(value);
18
18
  return proto === Object.prototype || proto === null;
19
19
  }
20
- function object(value, keys, censor = DefaultCensor, seen = new WeakSet()) {
20
+ function object(value, keys, censor = DefaultCensor, copies = new WeakMap()) {
21
21
  if (value == null || typeof value !== "object") {
22
22
  return value;
23
23
  }
24
24
  if (value instanceof Error) {
25
25
  return value;
26
26
  }
27
- if (seen.has(value)) {
28
- return value;
27
+ // A cycle in the input stays a cycle in the copy.
28
+ const copied = copies.get(value);
29
+ if (copied !== undefined) {
30
+ return copied;
29
31
  }
30
- seen.add(value);
31
32
  if (Array.isArray(value)) {
32
- return value.map((item)=>object(item, keys, censor, seen));
33
+ const out = [];
34
+ copies.set(value, out);
35
+ for (const item of value){
36
+ out.push(object(item, keys, censor, copies));
37
+ }
38
+ return out;
33
39
  }
34
40
  if (!isPlainObject(value)) {
35
41
  return value;
36
42
  }
37
43
  const out = {};
44
+ copies.set(value, out);
38
45
  for (const [key, nested] of Object.entries(value)){
39
- out[key] = keys.has(key) ? censor : object(nested, keys, censor, seen);
46
+ out[key] = keys.has(key) ? censor : object(nested, keys, censor, copies);
40
47
  }
41
48
  return out;
42
49
  }
@@ -0,0 +1,17 @@
1
+ import { LogEntryAdapter } from "../log-entry.adapter.js";
2
+ /**
3
+ * Test double: keeps every entry that passes the level threshold, prints
4
+ * nothing. `scribe.configure({ adapter: capture })`, run the code, then assert
5
+ * on `capture.entries`.
6
+ */ export class CaptureAdapter extends LogEntryAdapter {
7
+ entries = [];
8
+ /** Entries at `level`. */ at(level) {
9
+ return this.entries.filter((entry)=>entry.level === level);
10
+ }
11
+ clear() {
12
+ this.entries.length = 0;
13
+ }
14
+ write(entry) {
15
+ this.entries.push(entry);
16
+ }
17
+ }
@@ -1 +1 @@
1
- export { };
1
+ export * from "./capture.adapter.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/scribe",
3
- "version": "0.0.1-dev.119",
3
+ "version": "0.0.1-dev.120",
4
4
  "description": "Structured logging interface with pluggable adapters",
5
5
  "keywords": [
6
6
  "typescript",
@@ -17,6 +17,11 @@
17
17
  "types": "./types/src/index.d.ts",
18
18
  "import": "./es6/src/index.js",
19
19
  "require": "./commonjs/src/index.js"
20
+ },
21
+ "./testing": {
22
+ "types": "./types/src/testing/index.d.ts",
23
+ "import": "./es6/src/testing/index.js",
24
+ "require": "./commonjs/src/testing/index.js"
20
25
  }
21
26
  },
22
27
  "scripts": {
@@ -2,7 +2,7 @@ import type { DefaultLevels } from "./types.js";
2
2
  /**
3
3
  * Reads the log level from environment variables.
4
4
  * Checks LOG_LEVEL, SCRIBE_LOG_LEVEL, and LOGGING_LEVEL in order.
5
- * Returns null if none are set.
5
+ * Returns null if none are set, or if the runtime has no `process` (browsers).
6
6
  */
7
7
  export declare function getLogLevelFromEnv(): string | null;
8
8
  /**
@@ -1,6 +1,8 @@
1
1
  export * from "./console.adapter.js";
2
2
  export * from "./env.js";
3
3
  export * from "./errors.js";
4
+ export * from "./log-entry.adapter.js";
5
+ export * from "./log-entry.js";
4
6
  export * from "./logger.adapter.js";
5
7
  export * from "./logger.factory.js";
6
8
  export * from "./logger.type.js";
@@ -0,0 +1,20 @@
1
+ import { type LogEntry } from "./log-entry.js";
2
+ import { LogAdapter } from "./logger.adapter.js";
3
+ import type { Logger } from "./logger.type.js";
4
+ import { type ScribeOptions } from "./types.js";
5
+ /**
6
+ * Base for adapters that write structured entries. It owns levels, the level
7
+ * threshold, `child`, and censoring the `redactions` keys; a subclass only
8
+ * writes each `LogEntry`.
9
+ */
10
+ export declare abstract class LogEntryAdapter extends LogAdapter {
11
+ protected levels: Record<string, number>;
12
+ private level;
13
+ private threshold;
14
+ private redactionKeys;
15
+ configure<T extends string = never, CustomOnly extends true | false = false>(scribeOptions: ScribeOptions<T, CustomOnly>): void;
16
+ getLogger<T extends string = never, CustomOnly extends true | false = false>(name?: string): Logger<T, CustomOnly>;
17
+ /** Write one entry that passed the level threshold. */
18
+ protected abstract write(entry: LogEntry): void;
19
+ private createLogger;
20
+ }
@@ -0,0 +1,38 @@
1
+ import { type SerializedError } from "./logger.adapter.js";
2
+ /**
3
+ * One logged call. Adapters that write structured lines build it with
4
+ * `logEntry.from`, and taps receive it.
5
+ */
6
+ export type LogEntry = {
7
+ level: string;
8
+ name: string;
9
+ msg?: string;
10
+ err?: SerializedError;
11
+ /** Bound fields (`child`), then the call's own fields. */
12
+ fields: Record<string, unknown>;
13
+ };
14
+ export type LogEntryOptions = {
15
+ bindings?: Record<string, unknown>;
16
+ /**
17
+ * Keys the adapter censors (`redact.fromOptions`). Redaction is the
18
+ * adapter's job, so the facade's tap passes none.
19
+ */
20
+ redactionKeys?: ReadonlySet<string>;
21
+ };
22
+ /**
23
+ * Builds an entry from a log call's arguments:
24
+ *
25
+ * - `log.info("text")`, `log.info(42)` → `msg`
26
+ * - `log.error(err)` → `msg` is `err.message`, `err` the serialized error
27
+ * - `log.info({ fields })`, `log.info({ fields }, "text")` → `fields` (a string
28
+ * `msg` field becomes the message)
29
+ * - `log.info("text", { fields })` → `msg` + `fields`
30
+ * - `log.error("text", err)` → `msg` + `err`
31
+ * - `log.info("text", value)` → `msg` + `fields.data`; any further arguments go
32
+ * in `fields.args`
33
+ */
34
+ declare function from(level: string, name: string, [first, ...rest]: readonly unknown[], { bindings, redactionKeys }: LogEntryOptions): LogEntry;
35
+ export declare const logEntry: {
36
+ from: typeof from;
37
+ };
38
+ export {};
@@ -1,6 +1,22 @@
1
1
  import type { Logger } from "./logger.type.js";
2
2
  import { type ScribeOptions } from "./types.js";
3
3
  export declare function formatError(err: Error): string;
4
+ export type SerializedError = {
5
+ name: string;
6
+ message: string;
7
+ stack?: string;
8
+ cause?: unknown;
9
+ /** Own enumerable properties such as `code` or `statusCode`. */
10
+ [property: string]: unknown;
11
+ };
12
+ /** How many causes deep `serializeError` follows by default. */
13
+ export declare const DefaultErrorCauseDepth = 3;
14
+ /**
15
+ * Plain-object form of an error for structured logs. An `Error` cause, or an
16
+ * `Error` in another own property, is serialized the same way, up to
17
+ * `causeDepth` levels; any other value is kept as it is.
18
+ */
19
+ export declare function serializeError(err: Error, causeDepth?: number): SerializedError;
4
20
  export interface LoggerAdapter extends AsyncDisposable {
5
21
  configure<T extends string = never, CustomOnly extends true | false = false>(options: ScribeOptions<T, CustomOnly>): any;
6
22
  getLogger<T extends string = never, CustomOnly extends true | false = false>(name: string): Logger<T, CustomOnly>;
@@ -1,15 +1,26 @@
1
+ import { type LogEntry } from "./log-entry.js";
1
2
  import type { LoggerAdapter } from "./logger.adapter.js";
2
3
  import type { Logger } from "./logger.type.js";
3
4
  import { type ScribeOptions } from "./types.js";
4
5
  type LoggerFactoryOptions<T extends string = never, CustomOnly extends true | false = false> = {
5
6
  options?: ScribeOptions<T, CustomOnly>;
6
7
  adapter: LoggerAdapter;
8
+ /**
9
+ * Called with every entry a `scribe.logger(...)` handle writes (levels the
10
+ * adapter has enabled). For error reporters such as Sentry. Scribe does not
11
+ * redact what it hands the tap; the tap, or the service behind it, does.
12
+ * A tap that throws does not break the call that logged: the error is
13
+ * rethrown on a microtask, where the runtime reports it as uncaught.
14
+ */
15
+ tap?: LogTap;
7
16
  };
17
+ export type LogTap = (entry: LogEntry) => void;
8
18
  export declare class scribe implements AsyncDisposable {
9
19
  private loggerProvider;
10
20
  /** True after `configure({ adapter })` — the constructor’s ConsoleAdapter is only a fallback. */
11
21
  private adapterConfigured;
12
22
  private defaultAdapterWarned;
23
+ private tap;
13
24
  private static instance;
14
25
  static logger<T extends string = never, CustomOnly extends true | false = false>(name?: string, customLevels?: {
15
26
  [level in T]: number;
@@ -23,7 +34,8 @@ export declare class scribe implements AsyncDisposable {
23
34
  /**
24
35
  * Module-level `const log = scribe.logger(...)` must follow a later
25
36
  * `scribe.configure()` (CLI / Flux / HotReloadServer). Forward every use to
26
- * the active adapter logger for `name`.
37
+ * the active adapter logger for `name`, and `child(fields)` to a live handle
38
+ * that adds `bindings`, so children follow a reconfigure too.
27
39
  */
28
40
  private static createLiveLogger;
29
41
  constructor();
@@ -34,6 +46,7 @@ export declare class scribe implements AsyncDisposable {
34
46
  getLogger<T extends string = never, CustomOnly extends true | false = false>(name?: string, customLevels?: {
35
47
  [level in T]: number;
36
48
  }): Logger<T, CustomOnly>;
49
+ private notifyTap;
37
50
  private warnIfDefaultAdapter;
38
51
  private supportsLevels;
39
52
  private mergeDefaults;
@@ -10,4 +10,9 @@ export type Logger<CustomLevels extends string = never, CustomOnly extends true
10
10
  [Property in CustomLevels]: LogFn;
11
11
  } & {
12
12
  isLevelEnabled(level: string): boolean;
13
+ /**
14
+ * A logger with the same name and levels that adds `fields` (a request id, a
15
+ * job name) to every entry. Children of children merge their fields.
16
+ */
17
+ child(fields: Record<string, unknown>): Logger<CustomLevels, CustomOnly>;
13
18
  } & BaseLogger;
@@ -1,6 +1,6 @@
1
1
  import { type RedactionOptions } from "./types.js";
2
2
  declare function keysFromPaths(paths: string[]): Set<string>;
3
- declare function object(value: unknown, keys: ReadonlySet<string>, censor?: string, seen?: WeakSet<object>): unknown;
3
+ declare function object(value: unknown, keys: ReadonlySet<string>, censor?: string, copies?: WeakMap<object, unknown>): unknown;
4
4
  declare function fromOptions(options?: RedactionOptions): Set<string>;
5
5
  export declare const redact: {
6
6
  object: typeof object;
@@ -0,0 +1,14 @@
1
+ import { LogEntryAdapter } from "../log-entry.adapter.js";
2
+ import type { LogEntry } from "../log-entry.js";
3
+ /**
4
+ * Test double: keeps every entry that passes the level threshold, prints
5
+ * nothing. `scribe.configure({ adapter: capture })`, run the code, then assert
6
+ * on `capture.entries`.
7
+ */
8
+ export declare class CaptureAdapter extends LogEntryAdapter {
9
+ readonly entries: LogEntry[];
10
+ /** Entries at `level`. */
11
+ at(level: string): LogEntry[];
12
+ clear(): void;
13
+ protected write(entry: LogEntry): void;
14
+ }
@@ -1 +1 @@
1
- export {};
1
+ export * from "./capture.adapter.js";