unnbound-logger-sdk 3.1.2 → 3.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ ## [3.0.34](https://github.com/unnbounddev/unnbound-sdks/compare/unnbound-logger-sdk-v3.0.33...unnbound-logger-sdk-v3.0.34) (2025-11-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * setup ci/cd ([dd2640f](https://github.com/unnbounddev/unnbound-sdks/commit/dd2640f01e39f1342f3e08dc882164b50331c7bb))
7
+
8
+ ## [3.0.33](https://github.com/unnbounddev/unnbound-sdks/compare/unnbound-logger-sdk-v3.0.32...unnbound-logger-sdk-v3.0.33) (2025-11-25)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * setup ci/cd ([520c03a](https://github.com/unnbounddev/unnbound-sdks/commit/520c03aacd978d7923b3bcf45c4f74a870be019d))
14
+
15
+ ## [3.0.32](https://github.com/unnbounddev/unnbound-sdks/compare/unnbound-logger-sdk-v3.0.31...unnbound-logger-sdk-v3.0.32) (2025-11-25)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * setup ci/cd ([d3759c1](https://github.com/unnbounddev/unnbound-sdks/commit/d3759c14f7863e4c8d2f5e80bdbbf2446df79f1a))
21
+ * setup ci/cd ([7e06fc8](https://github.com/unnbounddev/unnbound-sdks/commit/7e06fc84ae7bafba3e9c2c0225f9f9d614fa9207))
package/README.md CHANGED
@@ -148,6 +148,26 @@ logger.error({ err: new Error('Connection refused') }, 'Failed to sync order.');
148
148
  Errors are serialized by Pino's `stdSerializers.err`, which emits `type`, `message`, and `stack`.
149
149
  Unlike the other levels, `error` has no single-argument overload, so the message is required.
150
150
 
151
+ ### Redaction Masks
152
+
153
+ `registerRedactionMasks` replaces each registered string with `[Redacted]` wherever it appears in a log line: messages, fields, errors, and child-logger bindings. `@ontemper/resources` registers the secrets it resolves; register other secret values yourself:
154
+
155
+ ```typescript
156
+ import { logger, registerRedactionMasks } from 'unnbound-logger-sdk';
157
+
158
+ registerRedactionMasks([apiKey]);
159
+ const partnerLog = logger.child({ partner: 'acme', auth: `Bearer ${apiKey}` });
160
+ partnerLog.info('Calling partner.');
161
+ // Result: { ..., "partner": "acme", "auth": "Bearer [Redacted]", "message": "Calling partner." }
162
+ ```
163
+
164
+ - Masks replace text in string values only. A property name that contains a secret is written unchanged.
165
+ - Masks apply to the finished line when it is written. A mask registered after `child()` or `setBindings()` still redacts those bindings, and the output of custom serializers and formatters is masked too.
166
+ - A mask registered inside a request scope applies to the whole scope, and stays active for the rest of the process after the scope ends. The process keeps at most 4096 of these retained masks and drops the oldest first; masks held by an active scope are not capped.
167
+ - A value that code transforms before logging (for example, Base64-encodes) no longer matches its mask.
168
+
169
+ Treat masking as a safeguard, not a guarantee: do not log secrets.
170
+
151
171
  ## HTTP Request/Response Logging
152
172
 
153
173
  ```typescript
package/dist/axios.d.ts CHANGED
@@ -3,7 +3,10 @@ import { startSpan } from './span';
3
3
  import { type HttpOptions } from './types';
4
4
  type GetPayload = (config: AxiosRequestConfig, res?: AxiosResponse) => object;
5
5
  type RedactOptions = {
6
+ request?: boolean;
7
+ requestHeaders?: boolean;
6
8
  response?: boolean;
9
+ responseHeaders?: boolean;
7
10
  };
8
11
  export type OnRequest = (config: AxiosRequestConfig, callback: (config: AxiosRequestConfig) => Promise<AxiosResponse<any>>) => Promise<AxiosResponse<any>>;
9
12
  interface HttpClientOptions extends HttpOptions<GetPayload> {
package/dist/axios.js CHANGED
@@ -16,25 +16,29 @@ const extractQueryParams = (url, config) => {
16
16
  keys.forEach((key) => object.searchParams.delete(key));
17
17
  return { url: object.toString(), query: keys.length > 0 ? query : undefined };
18
18
  };
19
- const buildOutgoingHttpPayload = (config, res, redact) => ({
20
- type: 'http',
21
- http: {
22
- ...extractQueryParams(buildUrl(config.baseURL, config.url), config),
23
- method: config.method?.toLowerCase() ?? 'get',
24
- incoming: false,
25
- request: {
26
- headers: config.headers,
27
- body: (0, utils_1.safeJsonParse)(config.data),
19
+ const buildOutgoingHttpPayload = (config, res, redact) => {
20
+ const { url, query } = extractQueryParams(buildUrl(config.baseURL, config.url), config);
21
+ return {
22
+ type: 'http',
23
+ http: {
24
+ url,
25
+ query: redact?.request && query ? '[Redacted]' : query,
26
+ method: config.method?.toLowerCase() ?? 'get',
27
+ incoming: false,
28
+ request: {
29
+ headers: redact?.request || redact?.requestHeaders ? '[Redacted]' : config.headers,
30
+ body: redact?.request ? '[Redacted]' : (0, utils_1.safeJsonParse)(config.data),
31
+ },
32
+ response: res
33
+ ? {
34
+ headers: redact?.response || redact?.responseHeaders ? '[Redacted]' : res.headers,
35
+ status: res.status,
36
+ body: redact?.response ? '[Redacted]' : (0, utils_1.safeJsonParse)(res.data),
37
+ }
38
+ : undefined,
28
39
  },
29
- response: res
30
- ? {
31
- headers: res?.headers,
32
- status: res.status,
33
- body: redact?.response ? '[Redacted]' : (0, utils_1.safeJsonParse)(res.data),
34
- }
35
- : undefined,
36
- },
37
- });
40
+ };
41
+ };
38
42
  const getNoopPayload = () => ({});
39
43
  const onNoopRequest = (config, callback) => callback(config);
40
44
  const tracedMarker = Symbol.for('unnbound-logger-sdk.traceAxios.traced');
@@ -54,7 +58,9 @@ const traceAxios = (client, { ignoreTraceRoutes = types_1.defaultIgnoreTraceRout
54
58
  }) => {
55
59
  if (tracedMarker in client)
56
60
  return client;
57
- const redact = redactOptions === true || redactOptions === false ? { response: redactOptions } : redactOptions;
61
+ const redact = redactOptions === true || redactOptions === false
62
+ ? { request: redactOptions, response: redactOptions }
63
+ : redactOptions;
58
64
  const createSpanWrappedRequest = (originalMethod, method) => {
59
65
  const { headers: defaultHeaders, ...partialDefaultConfig } = client.defaults;
60
66
  const headers = { ...defaultHeaders.common, ...(method && defaultHeaders[method]) };
package/dist/encode.d.ts CHANGED
@@ -1,2 +1,7 @@
1
+ /**
2
+ * Log records are read one line at a time — container stdout becomes one CloudWatch event per
3
+ * line and the log forwarder JSON-parses each event — so LF becomes CR to keep a value from
4
+ * splitting its own record. `whitespace-pre-wrap` still renders it as a break; `decode` restores LF.
5
+ */
1
6
  export declare const encode: (value: string) => string;
2
7
  export declare const decode: (value: string) => string;
package/dist/encode.js CHANGED
@@ -1,7 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.decode = exports.encode = void 0;
4
- const encode = (value) => value.replaceAll('\n', '\r');
4
+ const redaction_1 = require("./redaction");
5
+ /**
6
+ * Log records are read one line at a time — container stdout becomes one CloudWatch event per
7
+ * line and the log forwarder JSON-parses each event — so LF becomes CR to keep a value from
8
+ * splitting its own record. `whitespace-pre-wrap` still renders it as a break; `decode` restores LF.
9
+ */
10
+ const encode = (value) => (0, redaction_1.applyRedactionMasks)(value).replaceAll('\n', '\r');
5
11
  exports.encode = encode;
6
12
  const decode = (value) => value.replaceAll('\r', '\n');
7
13
  exports.decode = decode;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { decode, encode } from './encode';
5
5
  export type { ILogger, UnnboundLogger } from './logger';
6
6
  export { logger } from './logger';
7
7
  export { traceMiddleware } from './middleware';
8
+ export { applyRedactionMasks, registerRedactionMasks } from './redaction';
8
9
  export { startSpan } from './span';
9
10
  export type { UnnboundContext } from './storage';
10
11
  export { storage, withStorage } from './storage';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultTraceHeaderKey = exports.defaultMessageHeaderKey = exports.withTrace = exports.getTraceId = exports.withStorage = exports.storage = exports.startSpan = exports.traceMiddleware = exports.logger = exports.encode = exports.decode = exports.emitMetrics = exports.emitMetric = exports.traceAxios = void 0;
3
+ exports.defaultTraceHeaderKey = exports.defaultMessageHeaderKey = exports.withTrace = exports.getTraceId = exports.withStorage = exports.storage = exports.startSpan = exports.registerRedactionMasks = exports.applyRedactionMasks = exports.traceMiddleware = exports.logger = exports.encode = exports.decode = exports.emitMetrics = exports.emitMetric = exports.traceAxios = void 0;
4
4
  var axios_1 = require("./axios");
5
5
  Object.defineProperty(exports, "traceAxios", { enumerable: true, get: function () { return axios_1.traceAxios; } });
6
6
  var emf_1 = require("./emf");
@@ -13,6 +13,9 @@ var logger_1 = require("./logger");
13
13
  Object.defineProperty(exports, "logger", { enumerable: true, get: function () { return logger_1.logger; } });
14
14
  var middleware_1 = require("./middleware");
15
15
  Object.defineProperty(exports, "traceMiddleware", { enumerable: true, get: function () { return middleware_1.traceMiddleware; } });
16
+ var redaction_1 = require("./redaction");
17
+ Object.defineProperty(exports, "applyRedactionMasks", { enumerable: true, get: function () { return redaction_1.applyRedactionMasks; } });
18
+ Object.defineProperty(exports, "registerRedactionMasks", { enumerable: true, get: function () { return redaction_1.registerRedactionMasks; } });
16
19
  var span_1 = require("./span");
17
20
  Object.defineProperty(exports, "startSpan", { enumerable: true, get: function () { return span_1.startSpan; } });
18
21
  var storage_1 = require("./storage");
package/dist/logger.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import pino from 'pino';
2
- import type { Bindings, DestinationStream } from 'pino';
2
+ import type { Bindings, ChildLoggerOptions, DestinationStream } from 'pino';
3
3
  export interface ILogger {
4
4
  trace(object: Bindings, message: string): void;
5
5
  trace(message: string): void;
@@ -13,7 +13,8 @@ export interface ILogger {
13
13
  err: unknown;
14
14
  }>(object: O, message: string): void;
15
15
  }
16
- export interface UnnboundLogger extends ILogger, Omit<pino.Logger, keyof ILogger> {
16
+ export interface UnnboundLogger extends ILogger, Omit<pino.Logger, keyof ILogger | 'child'> {
17
+ child(bindings: Bindings, options?: ChildLoggerOptions): UnnboundLogger;
17
18
  }
18
19
  export declare const createLogger: (destination?: DestinationStream) => UnnboundLogger;
19
20
  export declare const logger: UnnboundLogger;
package/dist/logger.js CHANGED
@@ -8,6 +8,7 @@ const pino_1 = __importDefault(require("pino"));
8
8
  const uuid_1 = require("uuid");
9
9
  const encode_1 = require("./encode");
10
10
  const internal_1 = require("./internal");
11
+ const redaction_1 = require("./redaction");
11
12
  const storage_1 = require("./storage");
12
13
  // The Temper name wins while legacy fallbacks keep existing workflow images compatible.
13
14
  const workflowEnvironment = process.env.TEMPER_ENVIRONMENT ??
@@ -25,15 +26,7 @@ const isStringAnnotation = (value) => {
25
26
  return false;
26
27
  }
27
28
  };
28
- const formatLog = (log, visited = new WeakSet()) => {
29
- // Prevent infinite loops from circular references
30
- if (visited.has(log))
31
- return log;
32
- visited.add(log);
33
- // We can't encode frozen objects.
34
- if (Object.isFrozen(log))
35
- return log;
36
- // We don't use Object.entries() for performance reasons
29
+ const copyLogFields = (log, copy, copies, preserveErrorSerializer) => {
37
30
  for (const key in log) {
38
31
  if (!Object.hasOwn(log, key))
39
32
  continue;
@@ -42,23 +35,65 @@ const formatLog = (log, visited = new WeakSet()) => {
42
35
  if (key === '__proto__' || key === 'constructor' || key === 'prototype')
43
36
  continue;
44
37
  const value = log[key];
45
- // We don't want to encode undefined values.
46
- if (value === undefined)
47
- continue;
48
- // We can't encode immutable properties.
49
- if (!Object.getOwnPropertyDescriptor(log, key)?.writable)
50
- continue;
51
- if (isStringAnnotation(value)) {
52
- log[key] = (0, encode_1.encode)(value);
53
- }
54
- else if (Array.isArray(value)) {
55
- log[key] = value.map((item) => isStringAnnotation(item) ? (0, encode_1.encode)(item) : isBindings(item) ? formatLog(item, visited) : item);
56
- }
57
- else if (isBindings(value)) {
58
- log[key] = formatLog(value, visited);
38
+ if (value !== undefined) {
39
+ copy[key] =
40
+ preserveErrorSerializer && key === 'err' && value instanceof Error ? value : copyLogValue(value, copies);
59
41
  }
60
42
  }
61
- return log;
43
+ return copy;
44
+ };
45
+ const copyLogValue = (value, copies) => {
46
+ if (isStringAnnotation(value))
47
+ return (0, encode_1.encode)(value);
48
+ if (Array.isArray(value)) {
49
+ const existing = copies.get(value);
50
+ if (Array.isArray(existing))
51
+ return existing;
52
+ const copy = [];
53
+ copies.set(value, copy);
54
+ for (const item of value)
55
+ copy.push(copyLogValue(item, copies));
56
+ return copy;
57
+ }
58
+ if (value instanceof Error) {
59
+ const serialized = pino_1.default.stdSerializers.err(value);
60
+ const copy = {};
61
+ copies.set(value, copy);
62
+ copies.set(serialized, copy);
63
+ return copyLogFields(serialized, copy, copies, false);
64
+ }
65
+ if (isBindings(value) && Object.prototype.toString.call(value) === '[object Object]') {
66
+ return copyBindings(value, copies, false);
67
+ }
68
+ // Class instances such as AxiosHeaders serialize through toJSON, so the copy walks that output.
69
+ if (isBindings(value) && value.toJSON instanceof Function) {
70
+ const existing = copies.get(value);
71
+ if (existing !== undefined)
72
+ return existing;
73
+ const copy = copyLogValue(value.toJSON(), copies);
74
+ copies.set(value, copy);
75
+ return copy;
76
+ }
77
+ return value;
78
+ };
79
+ const copyBindings = (log, copies, preserveErrorSerializer) => {
80
+ // Pino hands serializers whatever the caller passed; a primitive is not a WeakMap key.
81
+ if (!isBindings(log))
82
+ return log;
83
+ const existing = copies.get(log);
84
+ if (isBindings(existing))
85
+ return existing;
86
+ const copy = {};
87
+ copies.set(log, copy);
88
+ return copyLogFields(log, copy, copies, preserveErrorSerializer);
89
+ };
90
+ const formatLog = (log) => copyBindings(log, new WeakMap(), true);
91
+ // `err` may be anything thrown; pino returns non-Error values unchanged, so only objects are copied.
92
+ const formatError = (error) => {
93
+ const serialized = pino_1.default.stdSerializers.err(error);
94
+ if (isStringAnnotation(serialized))
95
+ return (0, encode_1.encode)(serialized);
96
+ return isBindings(serialized) ? formatLog(serialized) : serialized;
62
97
  };
63
98
  const loggerOptions = {
64
99
  level: process.env.LOG_LEVEL ?? 'debug',
@@ -73,14 +108,14 @@ const loggerOptions = {
73
108
  serializers: {
74
109
  req: pino_1.default.stdSerializers.req,
75
110
  res: pino_1.default.stdSerializers.res,
76
- err: pino_1.default.stdSerializers.err,
111
+ err: formatError,
77
112
  message: encode_1.encode,
78
113
  },
79
114
  // Let CloudWatch handle timestamps
80
115
  timestamp: false,
81
116
  // Change message field from 'msg' to 'message'
82
117
  messageKey: 'message',
83
- formatters: { level: formatLevel, log: formatLog },
118
+ formatters: { bindings: formatLog, level: formatLevel, log: formatLog },
84
119
  hooks: {
85
120
  logMethod(args, method) {
86
121
  const firstArg = args[0];
@@ -98,6 +133,8 @@ const loggerOptions = {
98
133
  }
99
134
  activeMethod.apply(this, args);
100
135
  },
136
+ // Masks registered after a value was serialized (child bindings, formatter output) still reach the line.
137
+ streamWrite: redaction_1.redactLogLine,
101
138
  },
102
139
  redact: {
103
140
  paths: [
@@ -105,12 +142,26 @@ const loggerOptions = {
105
142
  'http.response.headers.authorization',
106
143
  'http.request.headers.Authorization',
107
144
  'http.response.headers.Authorization',
145
+ // The EDI client presents its gateway credential in this header, so it joins
146
+ // the Authorization paths: request evidence keeps the URL/status, never the key.
147
+ 'http.request.headers.ocp-apim-subscription-key',
148
+ 'http.request.headers.Ocp-Apim-Subscription-Key',
108
149
  'err.config',
109
150
  ],
110
151
  remove: true,
111
152
  },
112
153
  };
113
- const createLogger = (destination) => destination ? (0, pino_1.default)(loggerOptions, destination) : (0, pino_1.default)(loggerOptions);
154
+ // Pino children inherit the parent's own methods through Object.create, so every logger gets its own
155
+ // wrappers bound to itself; otherwise a child's `child`/`setBindings` would act on its parent.
156
+ const withRedaction = (instance, child, setBindings) => {
157
+ instance.child = (bindings, options) => withRedaction(child.call(instance, formatLog(bindings), options), child, setBindings);
158
+ instance.setBindings = (bindings) => setBindings.call(instance, formatLog(bindings));
159
+ return instance;
160
+ };
161
+ const createLogger = (destination) => {
162
+ const instance = destination ? (0, pino_1.default)(loggerOptions, destination) : (0, pino_1.default)(loggerOptions);
163
+ return withRedaction(instance, instance.child, instance.setBindings);
164
+ };
114
165
  exports.createLogger = createLogger;
115
166
  exports.logger = (0, exports.createLogger)();
116
167
  process.on('uncaughtException', (err, origin) => {
@@ -0,0 +1,3 @@
1
+ export declare function registerRedactionMasks(values: readonly string[]): void;
2
+ export declare function applyRedactionMasks(value: string): string;
3
+ export declare function redactLogLine(line: string): string;
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerRedactionMasks = registerRedactionMasks;
4
+ exports.applyRedactionMasks = applyRedactionMasks;
5
+ exports.redactLogLine = redactLogLine;
6
+ const storage_1 = require("./storage");
7
+ const REDACTED = '[Redacted]';
8
+ function registerRedactionMasks(values) {
9
+ const masks = (0, storage_1.getRedactionMasks)() ?? storage_1.processRedactionMasks;
10
+ for (const value of values) {
11
+ if (value.length > 0)
12
+ masks.add(value);
13
+ }
14
+ }
15
+ const indexMasks = (masks) => {
16
+ const root = { edge: '', end: false, children: new Map() };
17
+ for (const mask of masks) {
18
+ let node = root;
19
+ let rest = mask;
20
+ while (rest.length > 0) {
21
+ const child = node.children.get(rest.charCodeAt(0));
22
+ if (!child) {
23
+ node.children.set(rest.charCodeAt(0), { edge: rest, end: true, children: new Map() });
24
+ rest = '';
25
+ node = root;
26
+ break;
27
+ }
28
+ let common = 1;
29
+ while (common < child.edge.length && child.edge.charCodeAt(common) === rest.charCodeAt(common))
30
+ common += 1;
31
+ if (common < child.edge.length) {
32
+ const tail = { edge: child.edge.slice(common), end: child.end, children: child.children };
33
+ child.edge = child.edge.slice(0, common);
34
+ child.end = false;
35
+ child.children = new Map([[tail.edge.charCodeAt(0), tail]]);
36
+ }
37
+ node = child;
38
+ rest = rest.slice(common);
39
+ }
40
+ if (node !== root)
41
+ node.end = true;
42
+ }
43
+ return root;
44
+ };
45
+ const jsonEscaped = (value) => JSON.stringify(value).slice(1, -1);
46
+ // A written line holds each secret JSON-escaped, and `encode` may have turned LF into CR before serialization.
47
+ const lineForms = (mask) => [jsonEscaped(mask), jsonEscaped(mask.replaceAll('\n', '\r'))];
48
+ const indexes = new WeakMap();
49
+ const indexOf = (masks) => {
50
+ const cached = indexes.get(masks);
51
+ if (cached?.version === masks.version)
52
+ return cached;
53
+ const line = indexMasks(new Set([...masks].flatMap(lineForms)));
54
+ const built = { version: masks.version, plain: indexMasks(masks), line };
55
+ indexes.set(masks, built);
56
+ return built;
57
+ };
58
+ const indexesInReach = (mode) => {
59
+ const scope = (0, storage_1.getRedactionMasks)();
60
+ const all = scope ? [storage_1.processRedactionMasks, scope] : [storage_1.processRedactionMasks];
61
+ return all.filter((masks) => masks.size > 0).map((masks) => indexOf(masks)[mode]);
62
+ };
63
+ const matchLength = (reach, value, cursor) => {
64
+ let longest = 0;
65
+ for (const root of reach) {
66
+ let position = cursor;
67
+ let node = root.children.get(value.charCodeAt(position));
68
+ while (node && value.startsWith(node.edge, position)) {
69
+ position += node.edge.length;
70
+ if (node.end && position - cursor > longest)
71
+ longest = position - cursor;
72
+ node = node.children.get(value.charCodeAt(position));
73
+ }
74
+ }
75
+ return longest;
76
+ };
77
+ // In a JSON line only string values are replaced, so property names, numbers, and delimiters keep the line
78
+ // parseable, and the scan steps over whole escape sequences so a replacement never starts inside one.
79
+ const replaceMasks = (value, reach, jsonLine) => {
80
+ if (reach.length === 0)
81
+ return value;
82
+ let redacted = '';
83
+ let copied = 0;
84
+ let cursor = 0;
85
+ let inString = !jsonLine;
86
+ let masking = !jsonLine;
87
+ const containers = [];
88
+ let expectKey = false;
89
+ while (cursor < value.length) {
90
+ const unit = value[cursor];
91
+ if (jsonLine && !inString) {
92
+ if (unit === '"') {
93
+ inString = true;
94
+ masking = !expectKey;
95
+ }
96
+ else if (unit === '{' || unit === '[') {
97
+ containers.push(unit);
98
+ expectKey = unit === '{';
99
+ }
100
+ else if (unit === '}' || unit === ']') {
101
+ containers.pop();
102
+ }
103
+ else if (unit === ',') {
104
+ expectKey = containers.at(-1) === '{';
105
+ }
106
+ else if (unit === ':') {
107
+ expectKey = false;
108
+ }
109
+ cursor += 1;
110
+ continue;
111
+ }
112
+ if (jsonLine && unit === '"') {
113
+ inString = false;
114
+ cursor += 1;
115
+ continue;
116
+ }
117
+ const length = masking ? matchLength(reach, value, cursor) : 0;
118
+ if (length > 0) {
119
+ redacted += value.slice(copied, cursor) + REDACTED;
120
+ cursor += length;
121
+ copied = cursor;
122
+ continue;
123
+ }
124
+ cursor += jsonLine && unit === '\\' ? (value[cursor + 1] === 'u' ? 6 : 2) : 1;
125
+ }
126
+ return copied === 0 ? value : redacted + value.slice(copied);
127
+ };
128
+ function applyRedactionMasks(value) {
129
+ return replaceMasks(value, indexesInReach('plain'), false);
130
+ }
131
+ // Masks the finished JSON line, so a mask registered after a child froze its bindings, or a value a serializer or
132
+ // formatter produced, is still caught.
133
+ function redactLogLine(line) {
134
+ return replaceMasks(line, indexesInReach('line'), true);
135
+ }
package/dist/span.js CHANGED
@@ -13,8 +13,7 @@ const startSpan = async (spanName, callback, getter) => {
13
13
  const spanId = (0, uuid_1.v4)();
14
14
  const start = performance.now();
15
15
  const previous = storage_1.storage.getStore();
16
- return storage_1.storage.run({
17
- ...previous,
16
+ return (0, storage_1.runWithStorage)({
18
17
  traceId: previous?.traceId ?? (0, trace_1.getTraceId)(),
19
18
  spanId: previous?.spanId ? `${spanId} ${previous?.spanId}` : spanId,
20
19
  }, async () => {
package/dist/storage.d.ts CHANGED
@@ -4,9 +4,25 @@ export interface UnnboundContext {
4
4
  spanId?: string;
5
5
  messageId?: string;
6
6
  }
7
+ /** Process-wide masks are bounded by count: the least recently registered secret is evicted first. */
8
+ export declare const PROCESS_REDACTION_MASK_LIMIT = 4096;
9
+ export declare class MaskSet extends Set<string> {
10
+ version: number;
11
+ add(mask: string): this;
12
+ delete(mask: string): boolean;
13
+ clear(): void;
14
+ }
15
+ declare class RecentMaskSet extends MaskSet {
16
+ private readonly limit;
17
+ constructor(limit: number);
18
+ add(mask: string): this;
19
+ }
20
+ export declare const processRedactionMasks: RecentMaskSet;
7
21
  interface UnnboundStorage extends AsyncLocalStorage<object> {
8
22
  getStore<T>(): T | undefined;
9
23
  }
10
24
  export declare const storage: UnnboundStorage;
25
+ export declare const getRedactionMasks: () => MaskSet | undefined;
26
+ export declare const runWithStorage: <T, Data extends object>(data: Data, callback: () => T) => T;
11
27
  export declare const withStorage: <T, Data extends object>(data: Data, callback: () => Promise<T>) => Promise<T>;
12
28
  export {};
package/dist/storage.js CHANGED
@@ -1,12 +1,100 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.withStorage = exports.storage = void 0;
3
+ exports.withStorage = exports.runWithStorage = exports.getRedactionMasks = exports.storage = exports.processRedactionMasks = exports.MaskSet = exports.PROCESS_REDACTION_MASK_LIMIT = void 0;
4
4
  const node_async_hooks_1 = require("node:async_hooks");
5
+ const redactionMasksKey = Symbol('redactionMasks');
6
+ /** Process-wide masks are bounded by count: the least recently registered secret is evicted first. */
7
+ exports.PROCESS_REDACTION_MASK_LIMIT = 4096;
8
+ // `version` changes only when membership does, so redaction rebuilds its mask index for a new or removed mask,
9
+ // not when a re-registration moves an existing one to the newest position.
10
+ class MaskSet extends Set {
11
+ version = 0;
12
+ add(mask) {
13
+ const present = Set.prototype.delete.call(this, mask);
14
+ Set.prototype.add.call(this, mask);
15
+ if (!present)
16
+ this.version += 1;
17
+ return this;
18
+ }
19
+ delete(mask) {
20
+ const removed = super.delete(mask);
21
+ if (removed)
22
+ this.version += 1;
23
+ return removed;
24
+ }
25
+ clear() {
26
+ this.version += 1;
27
+ super.clear();
28
+ }
29
+ }
30
+ exports.MaskSet = MaskSet;
31
+ // Every resolver read re-registers its secrets, so one still being resolved stays resident while cold ones age out.
32
+ class RecentMaskSet extends MaskSet {
33
+ limit;
34
+ constructor(limit) {
35
+ super();
36
+ this.limit = limit;
37
+ }
38
+ add(mask) {
39
+ super.add(mask);
40
+ if (this.size > this.limit) {
41
+ const oldest = this.values().next();
42
+ if (!oldest.done)
43
+ this.delete(oldest.value);
44
+ }
45
+ return this;
46
+ }
47
+ }
48
+ exports.processRedactionMasks = new RecentMaskSet(exports.PROCESS_REDACTION_MASK_LIMIT);
49
+ // A root scope retains its masks once; detached work (a timer, an unawaited promise) that registers after that
50
+ // point forwards to the process set for logs outside the scope, and keeps its own copy, which the cap cannot evict.
51
+ class ScopeMasks extends MaskSet {
52
+ retained = false;
53
+ // Re-registration moves the mask to the end, so retention hands the process set the scope's masks by recency.
54
+ // A mask the process set already holds is refreshed there too: another trace may log it while this scope is open.
55
+ add(mask) {
56
+ if (this.retained || exports.processRedactionMasks.has(mask))
57
+ exports.processRedactionMasks.add(mask);
58
+ super.add(mask);
59
+ return this;
60
+ }
61
+ }
5
62
  exports.storage =
6
63
  // SAFETY: UnnboundStorage only adds a typed getStore helper over AsyncLocalStorage
7
64
  new node_async_hooks_1.AsyncLocalStorage();
8
- const withStorage = async (data, callback) => {
65
+ const getRedactionMasks = () => exports.storage.getStore()?.[redactionMasksKey];
66
+ exports.getRedactionMasks = getRedactionMasks;
67
+ const retainMasks = (masks) => {
68
+ masks.retained = true;
69
+ for (const mask of masks)
70
+ exports.processRedactionMasks.add(mask);
71
+ };
72
+ // Nested scopes share the root's mask set; a root scope's secrets are returned and logged after it
73
+ // exits, so its masks move to the process set on completion.
74
+ const runWithStorage = (data, callback) => {
9
75
  const previous = exports.storage.getStore();
10
- return exports.storage.run({ ...previous, ...data }, callback);
76
+ const inherited = previous?.[redactionMasksKey];
77
+ const masks = inherited ?? new ScopeMasks();
78
+ const context = { ...previous, ...data, [redactionMasksKey]: masks };
79
+ if (inherited)
80
+ return exports.storage.run(context, callback);
81
+ let result;
82
+ try {
83
+ result = exports.storage.run(context, callback);
84
+ }
85
+ catch (error) {
86
+ retainMasks(masks);
87
+ throw error;
88
+ }
89
+ if (result instanceof Promise) {
90
+ result.finally(() => retainMasks(masks)).catch(() => undefined);
91
+ return result;
92
+ }
93
+ retainMasks(masks);
94
+ return result;
95
+ };
96
+ exports.runWithStorage = runWithStorage;
97
+ const withStorage = async (data, callback) => {
98
+ return (0, exports.runWithStorage)(data, callback);
11
99
  };
12
100
  exports.withStorage = withStorage;
package/dist/trace.js CHANGED
@@ -13,6 +13,6 @@ const withTrace = (callback, extra) => {
13
13
  const previous = storage_1.storage.getStore();
14
14
  const traceId = extra?.traceId ?? previous?.traceId ?? (0, exports.getTraceId)();
15
15
  const messageId = extra?.messageId ?? previous?.messageId ?? (0, exports.getMessageId)();
16
- return storage_1.storage.run({ ...previous, ...extra, traceId, messageId }, callback);
16
+ return (0, storage_1.runWithStorage)({ ...extra, traceId, messageId }, callback);
17
17
  };
18
18
  exports.withTrace = withTrace;
package/dist/types.d.ts CHANGED
@@ -51,6 +51,7 @@ export interface SftpPayload {
51
51
  files?: string[];
52
52
  content?: string;
53
53
  exists?: string | false;
54
+ mode?: number | string;
54
55
  }
55
56
  export type EdiX12Operation = 'fromX12' | 'toX12' | 'validateX12' | 'acknowledgeX12';
56
57
  export type EdiOperation = EdiX12Operation;
package/dist/utils.d.ts CHANGED
@@ -1,6 +1,10 @@
1
- /** patterns are globs (`*`, `?`) matched against the whole path. */
1
+ /** patterns are globs (`*`, `?`) matched against the whole path; any other metacharacter is literal. */
2
2
  export declare const shouldIgnorePath: (path: string, patterns: string[]) => boolean;
3
+ type ParsedJson = string | number | boolean | null | ParsedJson[] | {
4
+ [key: string]: ParsedJson;
5
+ };
3
6
  /** Non-string or invalid JSON → input returned unchanged. */
4
- export declare function safeJsonParse(data: any): any;
7
+ export declare function safeJsonParse<Body>(data: Body): Body | ParsedJson;
5
8
  /** Strips the IPv4-mapped IPv6 prefix (::ffff:). */
6
9
  export declare function normalizeIp(ip: string | undefined): string | undefined;
10
+ export {};
package/dist/utils.js CHANGED
@@ -3,10 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.shouldIgnorePath = void 0;
4
4
  exports.safeJsonParse = safeJsonParse;
5
5
  exports.normalizeIp = normalizeIp;
6
- /** patterns are globs (`*`, `?`) matched against the whole path. */
6
+ /** patterns are globs (`*`, `?`) matched against the whole path; any other metacharacter is literal. */
7
7
  const shouldIgnorePath = (path, patterns) => {
8
8
  return patterns.some((pattern) => {
9
- const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
9
+ const regexPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, (character) => character === '*' ? '.*' : character === '?' ? '.' : `\\${character}`);
10
10
  const regex = new RegExp(`^${regexPattern}$`);
11
11
  return regex.test(path);
12
12
  });
@@ -14,7 +14,7 @@ const shouldIgnorePath = (path, patterns) => {
14
14
  exports.shouldIgnorePath = shouldIgnorePath;
15
15
  /** Non-string or invalid JSON → input returned unchanged. */
16
16
  function safeJsonParse(data) {
17
- if (String(data) === data) {
17
+ if (typeof data === 'string') {
18
18
  try {
19
19
  return JSON.parse(data);
20
20
  }
package/package.json CHANGED
@@ -1,19 +1,9 @@
1
1
  {
2
2
  "name": "unnbound-logger-sdk",
3
3
  "description": "A structured logging library with TypeScript support using Pino. Provides consistent, well-typed logging with automatic logId, workflowId, traceId, and deploymentId tracking across operational contexts.",
4
- "version": "3.1.2",
4
+ "version": "3.2.0",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
- "scripts": {
8
- "build": "tsc",
9
- "test": "vitest run src",
10
- "typecheck": "tsc --noEmit",
11
- "format": "oxfmt --write .",
12
- "format:check": "oxfmt --check .",
13
- "prepublishOnly": "npm run build",
14
- "start:example": "tsx watch examples/node-express.ts",
15
- "version:bump": "npm version patch"
16
- },
17
7
  "keywords": [
18
8
  "logging",
19
9
  "structured-logging",
@@ -66,5 +56,14 @@
66
56
  "engines": {
67
57
  "node": ">=22"
68
58
  },
69
- "sideEffects": false
70
- }
59
+ "sideEffects": false,
60
+ "scripts": {
61
+ "build": "tsc",
62
+ "test": "vitest run src",
63
+ "typecheck": "tsc --noEmit",
64
+ "format": "oxfmt --write .",
65
+ "format:check": "oxfmt --check .",
66
+ "start:example": "tsx watch examples/node-express.ts",
67
+ "version:bump": "npm version patch"
68
+ }
69
+ }