@zudojs/logger 1.0.0 → 1.1.0

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.
package/README.md CHANGED
@@ -42,6 +42,12 @@ included — implement the `LoggerTransport` interface for those.
42
42
  `transportTimeout` (default 10s) bounds every transport write, so a
43
43
  transport that stops responding cannot hang `flush()` or `close()`.
44
44
 
45
+ `throwTransportErrors` (default `false`) rethrows transport and formatter
46
+ failures instead of dropping them. A synchronous transport throws from the
47
+ log call itself; a failure from an asynchronous transport (or with
48
+ `asynchronous: true`) cannot, so it is rethrown by the next `flush()` or
49
+ `close()`, which still flush and close the transports first.
50
+
45
51
  ## Flushing
46
52
 
47
53
  Dispatch completes synchronously when every transport is synchronous.
@@ -71,7 +77,7 @@ createLogger({ redact: { enabled: false } }); // opt out
71
77
  Text-shaped formatters escape control characters in the message, the
72
78
  logger name, metadata keys and values, context values and source
73
79
  locations. A newline or ANSI escape inside attacker-supplied text
74
- becomes `\n` / `` rather than forging an extra log record or
80
+ becomes `\n` / `\u001b` rather than forging an extra log record or
75
81
  driving the operator's terminal. The JSON formatter relies on
76
82
  `JSON.stringify`, which escapes the same characters.
77
83
 
@@ -94,6 +100,11 @@ withLoggerContext(logger, createLoggerContext({ traceId }), (scoped) => {
94
100
  });
95
101
  ```
96
102
 
103
+ Per-call context is merged into the entry's metadata the same way:
104
+ `logger.log(LoggerLevel.INFO, "handled", { context: { tenant: "acme" } })`.
105
+ The entry's `context` also carries the identifiers (`requestId`, `traceId`,
106
+ ...) of the active context for transports that read them directly.
107
+
97
108
  Child loggers inherit name, level, formatter, transports, metadata and
98
109
  redaction settings: `logger.child({ name: "api.db" })`.
99
110
 
@@ -31,7 +31,10 @@ export declare class ZudojsLogger implements Logger, ZudojsLoggerContext {
31
31
  private _configuration;
32
32
  private readonly _contextStorage;
33
33
  private _disposed;
34
+ private _closing;
34
35
  private readonly _pending;
36
+ private readonly _dispatchFailures;
37
+ private _droppedFailures;
35
38
  constructor(options?: LoggerOptions, contextStorage?: LoggerContextStorage);
36
39
  get configuration(): LoggerConfiguration;
37
40
  get contextStorage(): LoggerContextStorage;
@@ -7,6 +7,8 @@ import { resolveLoggerOptions } from "../../loggerOptions/loggerOptions.type.js"
7
7
  import { normalizeConfiguration, assertActive as assertActiveHelper, assertMutable as assertMutableHelper, handleInfrastructureError as handleInfrastructureErrorHelper, } from "../helpers/loggerCoreHelpers.js";
8
8
  import { logAtLevel, childLogger, withContextLogger, setLoggerLevel, enableLogger, disableLogger, flushLogger, closeLogger, } from "../helpers/loggerCoreMethods/index.js";
9
9
  import { getLoggerName, getLoggerLevel, getLoggerEnabled, } from "../helpers/loggerCoreMethods.loggerProps.js";
10
+ /** Upper bound on dispatch failures retained between two flushes. */
11
+ const MAX_RETAINED_DISPATCH_FAILURES = 32;
10
12
  /**
11
13
  * Logger implementation.
12
14
  */
@@ -14,7 +16,10 @@ export class ZudojsLogger {
14
16
  _configuration;
15
17
  _contextStorage;
16
18
  _disposed = false;
19
+ _closing;
17
20
  _pending = new Set();
21
+ _dispatchFailures = [];
22
+ _droppedFailures = 0;
18
23
  constructor(options = {}, contextStorage) {
19
24
  this._configuration = resolveLoggerOptions(options);
20
25
  this._contextStorage = contextStorage ?? createLoggerContextStorage();
@@ -75,7 +80,14 @@ export class ZudojsLogger {
75
80
  return flushLogger(this);
76
81
  }
77
82
  close() {
78
- return closeLogger(this);
83
+ // Concurrent callers share one closure: two overlapping close() calls
84
+ // used to drain and close every transport twice.
85
+ if (this._closing)
86
+ return this._closing;
87
+ this._closing = closeLogger(this).finally(() => {
88
+ this._closing = undefined;
89
+ });
90
+ return this._closing;
79
91
  }
80
92
  assertActive() {
81
93
  assertActiveHelper(this._disposed, this._configuration.name);
@@ -99,11 +111,23 @@ export class ZudojsLogger {
99
111
  return new ZudojsLogger(options, this._contextStorage);
100
112
  }
101
113
  trackDispatch(dispatch) {
102
- // dispatchEntry routes its own failures through handleError, so a
103
- // rejection here is unexpected; swallow it to keep an unawaited log
104
- // call from crashing the process, and keep the entry drainable.
114
+ // A dispatch rejects only when handleError threw — i.e. when
115
+ // `throwTransportErrors` is on and an asynchronous transport failed.
116
+ // Nothing can throw from the log call that started it, so the failure
117
+ // is kept (bounded) and surfaced by the next flush()/close(); it was
118
+ // previously swallowed outright, which made the option a no-op for
119
+ // every asynchronous transport.
105
120
  const tracked = dispatch
106
- .catch(() => { })
121
+ .then(() => { }, (error) => {
122
+ if (!this._configuration.throwTransportErrors)
123
+ return;
124
+ if (this._dispatchFailures.length < MAX_RETAINED_DISPATCH_FAILURES) {
125
+ this._dispatchFailures.push(error);
126
+ }
127
+ else {
128
+ this._droppedFailures += 1;
129
+ }
130
+ })
107
131
  .finally(() => {
108
132
  this._pending.delete(tracked);
109
133
  });
@@ -115,6 +139,14 @@ export class ZudojsLogger {
115
139
  while (this._pending.size > 0) {
116
140
  await Promise.all([...this._pending]);
117
141
  }
142
+ if (this._dispatchFailures.length === 0)
143
+ return;
144
+ const failures = this._dispatchFailures.splice(0);
145
+ const dropped = this._droppedFailures;
146
+ this._droppedFailures = 0;
147
+ if (failures.length === 1 && dropped === 0)
148
+ throw failures[0];
149
+ throw new AggregateError(failures, `${failures.length + dropped} log dispatch(es) failed since the last flush.`);
118
150
  }
119
151
  }
120
152
  /**
@@ -14,9 +14,14 @@ export function createEntry(configuration, contextStorage, level, message, optio
14
14
  const contextMetadata = activeContext
15
15
  ? contextToLogMetadata(activeContext)
16
16
  : {};
17
+ // Per-call `options.context` flows into metadata exactly like the ambient
18
+ // context does. It used to reach only `entry.context.metadata`, which the
19
+ // text formatters never print, so `log(level, msg, { context })` silently
20
+ // dropped the data from every text-shaped line.
17
21
  const rawMetadata = {
18
22
  ...configuration.metadata,
19
23
  ...contextMetadata,
24
+ ...(options.context ?? {}),
20
25
  ...(options.metadata ?? {}),
21
26
  };
22
27
  // Redaction is applied HERE, before the entry is frozen, so every
@@ -35,8 +40,12 @@ export function createEntry(configuration, contextStorage, level, message, optio
35
40
  level,
36
41
  message,
37
42
  metadata,
43
+ // `LoggerEntryContext` declares the correlation identifiers, but they
44
+ // were never copied here, so a transport reading `entry.context.requestId`
45
+ // always saw `undefined`.
38
46
  context: context
39
47
  ? {
48
+ ...context.identifiers,
40
49
  metadata: redactLogValue(context.metadata, isSecret, replacement),
41
50
  }
42
51
  : undefined,
@@ -50,8 +50,18 @@ export async function flushLogger(ctx) {
50
50
  ctx.assertActive();
51
51
  // In-flight dispatches must land in the transports before those
52
52
  // transports are asked to flush, otherwise flush() is a no-op for
53
- // everything logged in the same tick.
54
- await ctx.drainDispatches();
53
+ // everything logged in the same tick. A dispatch failure (surfaced
54
+ // when `throwTransportErrors` is on) is rethrown only after the
55
+ // transports have still been flushed.
56
+ let failure;
57
+ let failed = false;
58
+ try {
59
+ await ctx.drainDispatches();
60
+ }
61
+ catch (error) {
62
+ failure = error;
63
+ failed = true;
64
+ }
55
65
  for (const transport of ctx.configuration.transports) {
56
66
  if (!isLoggerTransport(transport)) {
57
67
  continue;
@@ -64,6 +74,8 @@ export async function flushLogger(ctx) {
64
74
  await registered.flush();
65
75
  }
66
76
  }
77
+ if (failed)
78
+ throw failure;
67
79
  }
68
80
  /**
69
81
  * Closes all transports and marks logger as disposed.
@@ -72,7 +84,18 @@ export async function closeLogger(ctx) {
72
84
  if (ctx.isDisposed()) {
73
85
  return;
74
86
  }
75
- await ctx.drainDispatches();
87
+ // Closing is terminal: transports are flushed and closed and the logger
88
+ // is marked disposed even when a dispatch failed; the failure is rethrown
89
+ // afterwards.
90
+ let failure;
91
+ let failed = false;
92
+ try {
93
+ await ctx.drainDispatches();
94
+ }
95
+ catch (error) {
96
+ failure = error;
97
+ failed = true;
98
+ }
76
99
  for (const transport of ctx.configuration.transports) {
77
100
  if (!isLoggerTransport(transport)) {
78
101
  continue;
@@ -86,5 +109,7 @@ export async function closeLogger(ctx) {
86
109
  }
87
110
  }
88
111
  ctx.markDisposed();
112
+ if (failed)
113
+ throw failure;
89
114
  }
90
115
  //# sourceMappingURL=loggerCoreMethods.lifecycle.js.map
@@ -62,7 +62,13 @@ export function createSecretMatcher(options = {}) {
62
62
  if (options.enabled === false) {
63
63
  return () => false;
64
64
  }
65
- const pattern = options.pattern ?? DEFAULT_LOGGER_SECRET_PATTERN;
65
+ // Copy the pattern without `g`/`y`: those flags make `test()` advance
66
+ // `lastIndex`, so a shared pattern would match a secret-named field on
67
+ // one entry and let it through unredacted on the next.
68
+ const configured = options.pattern ?? DEFAULT_LOGGER_SECRET_PATTERN;
69
+ const pattern = configured.global || configured.sticky
70
+ ? new RegExp(configured.source, configured.flags.replace(/[gy]/gu, ""))
71
+ : configured;
66
72
  const exact = new Set((options.keys ?? []).map((key) => key.toLowerCase()));
67
73
  return (key) => exact.has(key.toLowerCase()) || pattern.test(key);
68
74
  }
@@ -17,6 +17,12 @@ export function formatContext(entry) {
17
17
  if (typeof value === "object") {
18
18
  continue;
19
19
  }
20
+ // Context identifiers are also flattened into metadata; skip the ones
21
+ // the metadata block already prints so a line never repeats itself.
22
+ if (Object.hasOwn(entry.metadata, key) &&
23
+ entry.metadata[key] === value) {
24
+ continue;
25
+ }
20
26
  values.push(`${escapeLogText(key)}=${escapeLogText(String(value))}`);
21
27
  }
22
28
  return values.length > 0 ? `[${values.join(" ")}]` : "";
@@ -34,6 +34,10 @@ export function createBufferedLoggerTransport(transport, options = {}) {
34
34
  /* deliberate no-op */
35
35
  }
36
36
  }, flushInterval);
37
+ // A pending flush is housekeeping, not work: left referenced it kept a
38
+ // finished process alive for a full `flushInterval`. close() flushes
39
+ // whatever is buffered, so nothing is lost by letting the loop exit.
40
+ timer.unref?.();
37
41
  };
38
42
  const buffered = {
39
43
  name: options.name ?? "buffered",
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/logger",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Structured logging with transports, log levels, and context propagation for Zudojs applications.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -24,7 +28,7 @@
24
28
  "node": ">=24.0.0"
25
29
  },
26
30
  "dependencies": {
27
- "@zudojs/errors": "1.0.0"
31
+ "@zudojs/errors": "1.0.1"
28
32
  },
29
33
  "devDependencies": {
30
34
  "typescript": "7.0.2",