@amkisko/json-logging 0.1.1 → 0.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Structured JSON logging for Bun, Node, Deno, Next.js, and Remix. The portable API follows the Ruby `activesupport-json_logging` gem style: create a logger, call severity methods, use `tagged` and `withContext`, and emit one JSON object per line.
4
4
 
5
- Zero runtime dependencies. Ambient `withContext` / block `tagged` use AsyncLocalStorage when the runtime exposes it through `process.getBuiltinModule("async_hooks")` (Node, Bun, Deno with Node builtins, Cloudflare Workers with `nodejs_compat`). The package never statically imports `node:async_hooks`, so browsers and Workers without Node builtins can still import and emit basic JSON lines; ambient context is a no-op there. Call `isAsyncLocalStorageAvailable()` to detect ALS. Helpers under `@amkisko/json-logging/next` and `@amkisko/json-logging/remix` use Web APIs and do not add framework packages as runtime dependencies.
5
+ Zero runtime dependencies. Ambient `withContext` / block `tagged` use AsyncLocalStorage when the runtime exposes it through `process.getBuiltinModule("async_hooks")` (Node, Bun, Deno with Node builtins, Cloudflare Workers with `nodejs_compat`). The package never statically imports `node:async_hooks`, so browsers and Workers without Node builtins can still import and emit basic JSON lines; ambient context is a no-op there. On Workers that import `AsyncLocalStorage` from `node:async_hooks` themselves, call `configureContextStorage(new AsyncLocalStorage())` once at boot. Call `isAsyncLocalStorageAvailable()` to detect ALS. Helpers under `@amkisko/json-logging/next` and `@amkisko/json-logging/remix` use Web APIs and do not add framework packages as runtime dependencies.
6
6
 
7
7
  ## Runtime compatibility
8
8
 
@@ -11,7 +11,7 @@ Zero runtime dependencies. Ambient `withContext` / block `tagged` use AsyncLocal
11
11
  | Node.js | Compatible | Primary target; ALS context works. |
12
12
  | Bun | Compatible | Tested in CI; `@amkisko/json-logging/bun` for `Bun.serve`. |
13
13
  | Deno | Compatible | ALS via Deno Node builtins (`getBuiltinModule`). |
14
- | Cloudflare Workers | Compatible | Full ALS with `nodejs_compat`. Without it: import and basic logging still work; `withContext` does not retain fields. |
14
+ | Cloudflare Workers | Compatible | Full ALS with `nodejs_compat` (soft-load or `configureContextStorage`). Without it: import and basic logging still work; `withContext` does not retain fields unless you inject ALS. |
15
15
  | Browsers | Compatible | Basic logging and sinks work. Ambient context requires a host that provides ALS (unusual in browsers). |
16
16
 
17
17
  On the JSR package settings page, mark Node.js, Bun, Deno, Cloudflare Workers, and Browsers as compatible. Keep the package description aligned with `package.json` (`JSR_TOKEN=... ./usr/bin/sync-jsr-settings`, or set it in the Settings tab).
@@ -64,12 +64,17 @@ Each line looks like:
64
64
 
65
65
  Object fields merge at the root. Tags appear as `tags`. Request fields appear under `context`. System keys (`tags`, `severity`, `timestamp`, `message`, `context`) are reserved.
66
66
 
67
- Optional hot-path helpers (RFC-0002):
67
+ Optional hot-path helpers (RFC-0002, RFC-0005):
68
68
 
69
69
  ```ts
70
70
  const logger = createLogger({
71
71
  level: "info",
72
- redact: ["user.password", "headers.authorization"],
72
+ redact: {
73
+ paths: ["user.password", "headers.authorization"],
74
+ keys: ["ssn", "apiKey"],
75
+ caseInsensitive: true,
76
+ censor: "hash", // or "filtered" | "remove" | (value) => unknown
77
+ },
73
78
  serializers: {
74
79
  err: (value) => value, // root-field transform
75
80
  },
@@ -80,6 +85,8 @@ if (logger.isLevelEnabled("debug")) {
80
85
  }
81
86
  ```
82
87
 
88
+ Path-only form `redact: ["user.password"]` still applies `[FILTERED]`.
89
+
83
90
  ## Sinks
84
91
 
85
92
  Console is the default. Pass a custom sink or a `WritableStream`:
package/dist/context.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /** Key/value fields stored in the ambient request context. */
2
2
  export type ContextFields = Record<string, unknown>;
3
- type Store = {
3
+ /** ALS store shape for {@link configureContextStorage} and ambient helpers. */
4
+ export type ContextStore = {
4
5
  context: ContextFields;
5
6
  tags: string[];
6
7
  };
8
+ type Store = ContextStore;
7
9
  /** Minimal ALS surface used for request/block context (run + getStore only). */
8
10
  export type AlsInstance<T> = {
9
11
  run<R>(store: T, callback: () => R): R;
@@ -11,8 +13,14 @@ export type AlsInstance<T> = {
11
13
  };
12
14
  /** Constructor for {@link AlsInstance}, matching `node:async_hooks`.AsyncLocalStorage. */
13
15
  export type AlsConstructor = new <T>() => AlsInstance<T>;
14
- /** True when this runtime can construct AsyncLocalStorage for ambient context. */
16
+ /** True when ambient context can use AsyncLocalStorage (injected or soft-loaded). */
15
17
  export declare function isAsyncLocalStorageAvailable(): boolean;
18
+ /**
19
+ * Inject an AsyncLocalStorage instance for ambient `withContext` / block `tagged`
20
+ * when soft-load via `process.getBuiltinModule` is unavailable (for example Workers
21
+ * that import `node:async_hooks` directly). Pass `null` to clear and restore soft-load.
22
+ */
23
+ export declare function configureContextStorage(storage: AlsInstance<ContextStore> | null): void;
16
24
  /** Test helper: force ALS constructor resolution (`null` = unavailable). */
17
25
  export declare function setAlsConstructorForTests(value: AlsConstructor | null | undefined): void;
18
26
  /** Test helper: pass null to simulate a runtime without AsyncLocalStorage. */
package/dist/context.js CHANGED
@@ -50,10 +50,24 @@ function loadAlsConstructor() {
50
50
  cachedConstructor = undefined;
51
51
  return undefined;
52
52
  }
53
- /** True when this runtime can construct AsyncLocalStorage for ambient context. */
53
+ /** True when ambient context can use AsyncLocalStorage (injected or soft-loaded). */
54
54
  export function isAsyncLocalStorageAvailable() {
55
+ if (alsOverride !== undefined && alsOverride !== null) {
56
+ return true;
57
+ }
58
+ if (alsOverride === null) {
59
+ return false;
60
+ }
55
61
  return loadAlsConstructor() !== undefined;
56
62
  }
63
+ /**
64
+ * Inject an AsyncLocalStorage instance for ambient `withContext` / block `tagged`
65
+ * when soft-load via `process.getBuiltinModule` is unavailable (for example Workers
66
+ * that import `node:async_hooks` directly). Pass `null` to clear and restore soft-load.
67
+ */
68
+ export function configureContextStorage(storage) {
69
+ alsOverride = storage === null ? undefined : storage;
70
+ }
57
71
  /** Test helper: force ALS constructor resolution (`null` = unavailable). */
58
72
  export function setAlsConstructorForTests(value) {
59
73
  alsConstructorOverride = value;
package/dist/index.d.ts CHANGED
@@ -15,17 +15,17 @@
15
15
  */
16
16
  export type { ConsoleMethodName, ConsoleMethods } from "./console-state.js";
17
17
  export { captureConsoleMethods, getBridgeOriginals, isInBridge, } from "./console-state.js";
18
- export type { AlsConstructor, AlsInstance, ContextFields } from "./context.js";
19
- export { getBlockTags, getContext, isAsyncLocalStorageAvailable, peekLogStore, withContext, } from "./context.js";
18
+ export type { AlsConstructor, AlsInstance, ContextFields, ContextStore } from "./context.js";
19
+ export { configureContextStorage, getBlockTags, getContext, isAsyncLocalStorageAvailable, peekLogStore, withContext, } from "./context.js";
20
20
  export { createLogger, JsonLogging } from "./json-logging.js";
21
21
  export { Logger } from "./logger.js";
22
- export type { CompiledRedactPath } from "./redact.js";
23
- export { applyCompiledRedactPaths, applyRedactPaths, compileRedactPaths, } from "./redact.js";
22
+ export type { CompiledRedact, CompiledRedactPath } from "./redact.js";
23
+ export { applyCompiledRedact, applyCompiledRedactPaths, applyRedactPaths, compileRedact, compileRedactPaths, hasRedactWork, } from "./redact.js";
24
24
  export { isSensitiveKey, MAX_CONTEXT_SIZE, MAX_DEPTH, MAX_STRING_LENGTH, prepareTags, sanitizeError, sanitizeHash, sanitizeString, } from "./sanitize.js";
25
25
  export { buildFallbackRecord, buildRecord, currentTimestamp, formatTimestamp, parseMessage, SYSTEM_KEYS, toJsonLine, } from "./serialize.js";
26
26
  export { isEnabled, LEVEL_ORDER, SEVERITY_NAMES, severityName } from "./severity.js";
27
27
  export { consoleSink, consoleSinkFrom, streamSink } from "./sinks.js";
28
28
  export type { PerformanceLike } from "./timestamp.js";
29
29
  export { defaultPerformance } from "./timestamp.js";
30
- export type { ContextProvider, FieldSerializer, JsonPrimitive, JsonValue, Level, LoggerOptions, LogMessage, LogRecord, SeverityName, Sink, } from "./types.js";
30
+ export type { ContextProvider, FieldSerializer, JsonPrimitive, JsonValue, Level, LoggerOptions, LogMessage, LogRecord, RedactCensor, RedactOptions, SeverityName, Sink, } from "./types.js";
31
31
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -14,10 +14,10 @@
14
14
  * @module
15
15
  */
16
16
  export { captureConsoleMethods, getBridgeOriginals, isInBridge, } from "./console-state.js";
17
- export { getBlockTags, getContext, isAsyncLocalStorageAvailable, peekLogStore, withContext, } from "./context.js";
17
+ export { configureContextStorage, getBlockTags, getContext, isAsyncLocalStorageAvailable, peekLogStore, withContext, } from "./context.js";
18
18
  export { createLogger, JsonLogging } from "./json-logging.js";
19
19
  export { Logger } from "./logger.js";
20
- export { applyCompiledRedactPaths, applyRedactPaths, compileRedactPaths, } from "./redact.js";
20
+ export { applyCompiledRedact, applyCompiledRedactPaths, applyRedactPaths, compileRedact, compileRedactPaths, hasRedactWork, } from "./redact.js";
21
21
  export { isSensitiveKey, MAX_CONTEXT_SIZE, MAX_DEPTH, MAX_STRING_LENGTH, prepareTags, sanitizeError, sanitizeHash, sanitizeString, } from "./sanitize.js";
22
22
  export { buildFallbackRecord, buildRecord, currentTimestamp, formatTimestamp, parseMessage, SYSTEM_KEYS, toJsonLine, } from "./serialize.js";
23
23
  export { isEnabled, LEVEL_ORDER, SEVERITY_NAMES, severityName } from "./severity.js";
package/dist/logger.d.ts CHANGED
@@ -11,12 +11,13 @@ export declare class Logger {
11
11
  readonly clock?: () => Date;
12
12
  /** Sticky tags from construction or {@link Logger.tagged}. */
13
13
  readonly tags: readonly string[];
14
- /** Configured redact paths (dot notation). */
14
+ /** Configured redact paths (dot notation); empty when only keys are set. */
15
15
  readonly redact: readonly string[];
16
16
  /** Root-field serializers. */
17
17
  readonly serializers: Readonly<Record<string, FieldSerializer>>;
18
18
  /** Optional ambient context provider. */
19
19
  readonly contextProvider?: ContextProvider;
20
+ private readonly redactSource;
20
21
  private readonly compiledRedact;
21
22
  constructor(options?: LoggerOptions);
22
23
  /** True when a message at `level` would be emitted. */
package/dist/logger.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { withBlockTags } from "./context.js";
2
- import { compileRedactPaths } from "./redact.js";
2
+ import { compileRedact, hasRedactWork } from "./redact.js";
3
3
  import { prepareTags, sanitizeHash } from "./sanitize.js";
4
4
  import { buildFallbackRecord, buildRecord, toJsonLine } from "./serialize.js";
5
5
  import { isEnabled } from "./severity.js";
@@ -7,6 +7,18 @@ import { consoleSink } from "./sinks.js";
7
7
  function isCallback(value) {
8
8
  return typeof value === "function";
9
9
  }
10
+ function isRedactOptions(value) {
11
+ return !Array.isArray(value);
12
+ }
13
+ function redactPathsFrom(input) {
14
+ if (input === undefined) {
15
+ return [];
16
+ }
17
+ if (isRedactOptions(input)) {
18
+ return input.paths ? [...input.paths] : [];
19
+ }
20
+ return [...input];
21
+ }
10
22
  /** Structured logger: severity methods, sticky tags, child loggers, never-raise emit path. */
11
23
  export class Logger {
12
24
  /** Minimum level that will be written. */
@@ -19,12 +31,13 @@ export class Logger {
19
31
  clock;
20
32
  /** Sticky tags from construction or {@link Logger.tagged}. */
21
33
  tags;
22
- /** Configured redact paths (dot notation). */
34
+ /** Configured redact paths (dot notation); empty when only keys are set. */
23
35
  redact;
24
36
  /** Root-field serializers. */
25
37
  serializers;
26
38
  /** Optional ambient context provider. */
27
39
  contextProvider;
40
+ redactSource;
28
41
  compiledRedact;
29
42
  constructor(options = {}) {
30
43
  this.level = options.level ?? "info";
@@ -32,10 +45,11 @@ export class Logger {
32
45
  this.base = options.base ? sanitizeHash(options.base) : {};
33
46
  this.clock = options.clock;
34
47
  this.tags = options.tags ? prepareTags(options.tags) : [];
35
- this.redact = options.redact ? [...options.redact] : [];
48
+ this.redactSource = options.redact;
49
+ this.redact = redactPathsFrom(options.redact);
36
50
  this.serializers = options.serializers ? { ...options.serializers } : {};
37
51
  this.contextProvider = options.contextProvider;
38
- this.compiledRedact = compileRedactPaths(this.redact);
52
+ this.compiledRedact = compileRedact(options.redact);
39
53
  }
40
54
  /** True when a message at `level` would be emitted. */
41
55
  isLevelEnabled(level) {
@@ -68,7 +82,7 @@ export class Logger {
68
82
  sink: this.sink,
69
83
  clock: this.clock,
70
84
  tags: this.tags,
71
- redact: this.redact,
85
+ redact: this.redactSource,
72
86
  serializers: this.serializers,
73
87
  contextProvider: this.contextProvider,
74
88
  base: { ...this.base, ...sanitizeHash(bindings) },
@@ -89,7 +103,7 @@ export class Logger {
89
103
  sink: this.sink,
90
104
  clock: this.clock,
91
105
  base: this.base,
92
- redact: this.redact,
106
+ redact: this.redactSource,
93
107
  serializers: this.serializers,
94
108
  contextProvider: this.contextProvider,
95
109
  tags: [...this.tags, ...tags],
@@ -108,7 +122,7 @@ export class Logger {
108
122
  preparedBase: true,
109
123
  preparedTags: true,
110
124
  clock: this.clock,
111
- compiledRedact: this.compiledRedact,
125
+ compiledRedact: hasRedactWork(this.compiledRedact) ? this.compiledRedact : undefined,
112
126
  serializers: this.serializers,
113
127
  contextProvider: this.contextProvider,
114
128
  });
package/dist/redact.d.ts CHANGED
@@ -1,9 +1,23 @@
1
+ import type { RedactOptions } from "./types.js";
1
2
  /** Dot-path segments after {@link compileRedactPaths} (config-time). */
2
3
  export type CompiledRedactPath = readonly string[];
4
+ /** Resolved redact plan used on the log hot path. */
5
+ export type CompiledRedact = {
6
+ paths: readonly CompiledRedactPath[];
7
+ keys: ReadonlySet<string>;
8
+ caseInsensitive: boolean;
9
+ replace: (value: unknown) => unknown;
10
+ };
3
11
  /** Compile redact paths once at logger construction. Config-time only. */
4
12
  export declare function compileRedactPaths(paths: readonly string[]): CompiledRedactPath[];
5
- /** Apply precompiled redact paths to a value tree. */
13
+ /** Compile path and key redact options once at logger construction. */
14
+ export declare function compileRedact(input: readonly string[] | RedactOptions | undefined): CompiledRedact;
15
+ /** Apply a compiled redact plan (paths then keys). */
16
+ export declare function applyCompiledRedact(value: unknown, compiled: CompiledRedact): unknown;
17
+ /** Apply precompiled path list with default `[FILTERED]` censor. */
6
18
  export declare function applyCompiledRedactPaths(value: unknown, paths: readonly CompiledRedactPath[]): unknown;
7
- /** Apply string paths (compiles each call). Prefer compileRedactPaths on hot paths. */
19
+ /** Apply string paths (compiles each call). Prefer compileRedact on hot paths. */
8
20
  export declare function applyRedactPaths(value: unknown, paths: readonly string[]): unknown;
21
+ /** True when the compiled plan has any paths or keys. */
22
+ export declare function hasRedactWork(compiled: CompiledRedact): boolean;
9
23
  //# sourceMappingURL=redact.d.ts.map
package/dist/redact.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { plainObjectFrom } from "./objects.js";
2
2
  const FILTERED = "[FILTERED]";
3
+ const REMOVED = Symbol("json-logging-removed");
3
4
  function splitPath(path) {
4
5
  if (path.length === 0) {
5
6
  return undefined;
@@ -21,57 +22,167 @@ export function compileRedactPaths(paths) {
21
22
  }
22
23
  return compiled;
23
24
  }
24
- function redactArray(value, parts, index, key) {
25
+ function fnv1a32Hex(text) {
26
+ let hash = 0x811c9dc5;
27
+ for (let i = 0; i < text.length; i += 1) {
28
+ hash ^= text.charCodeAt(i);
29
+ hash = Math.imul(hash, 0x01000193);
30
+ }
31
+ return (hash >>> 0).toString(16).padStart(8, "0");
32
+ }
33
+ function valueFingerprint(value) {
34
+ if (typeof value === "string") {
35
+ return value;
36
+ }
37
+ try {
38
+ return JSON.stringify(value) ?? String(value);
39
+ }
40
+ catch {
41
+ return String(value);
42
+ }
43
+ }
44
+ function resolveReplace(censor, hashLabel) {
45
+ if (censor === "remove") {
46
+ return () => REMOVED;
47
+ }
48
+ if (censor === "hash") {
49
+ const label = hashLabel.length > 0 ? hashLabel : "hash";
50
+ return (value) => `[${label}:${fnv1a32Hex(valueFingerprint(value))}]`;
51
+ }
52
+ if (typeof censor === "function") {
53
+ return censor;
54
+ }
55
+ return () => FILTERED;
56
+ }
57
+ function isRedactOptions(value) {
58
+ return !Array.isArray(value);
59
+ }
60
+ function normalizeOptions(input) {
61
+ if (input === undefined) {
62
+ return {};
63
+ }
64
+ if (isRedactOptions(input)) {
65
+ return input;
66
+ }
67
+ return { paths: input };
68
+ }
69
+ /** Compile path and key redact options once at logger construction. */
70
+ export function compileRedact(input) {
71
+ const options = normalizeOptions(input);
72
+ const caseInsensitive = options.caseInsensitive === true;
73
+ const keys = new Set();
74
+ for (const key of options.keys ?? []) {
75
+ if (key.length === 0) {
76
+ continue;
77
+ }
78
+ keys.add(caseInsensitive ? key.toLowerCase() : key);
79
+ }
80
+ return {
81
+ paths: compileRedactPaths(options.paths ?? []),
82
+ keys,
83
+ caseInsensitive,
84
+ replace: resolveReplace(options.censor, options.hashLabel ?? "hash"),
85
+ };
86
+ }
87
+ function keyMatches(name, compiled) {
88
+ return compiled.keys.has(compiled.caseInsensitive ? name.toLowerCase() : name);
89
+ }
90
+ function redactArray(value, parts, index, key, compiled) {
25
91
  if (key === "*") {
26
- return value.map((item) => redactAt(item, parts, index + 1));
92
+ return value.map((item) => redactAt(item, parts, index + 1, compiled));
27
93
  }
28
94
  const idx = Number(key);
29
95
  if (!Number.isInteger(idx) || idx < 0 || idx >= value.length) {
30
96
  return value;
31
97
  }
32
98
  const copy = value.slice();
33
- copy[idx] = redactAt(copy[idx], parts, index + 1);
99
+ const next = redactAt(copy[idx], parts, index + 1, compiled);
100
+ copy[idx] = next === REMOVED ? null : next;
34
101
  return copy;
35
102
  }
36
- function redactObject(value, parts, index, key) {
103
+ function redactObject(value, parts, index, key, compiled) {
37
104
  if (key === "*") {
38
105
  const out = {};
39
106
  for (const childKey of Object.keys(value)) {
40
- out[childKey] = redactAt(value[childKey], parts, index + 1);
107
+ const next = redactAt(value[childKey], parts, index + 1, compiled);
108
+ if (next !== REMOVED) {
109
+ out[childKey] = next;
110
+ }
41
111
  }
42
112
  return out;
43
113
  }
44
114
  if (!(key in value)) {
45
115
  return value;
46
116
  }
47
- return {
48
- ...value,
49
- [key]: redactAt(value[key], parts, index + 1),
50
- };
117
+ const next = redactAt(value[key], parts, index + 1, compiled);
118
+ if (next === REMOVED) {
119
+ const out = { ...value };
120
+ delete out[key];
121
+ return out;
122
+ }
123
+ return { ...value, [key]: next };
51
124
  }
52
- function redactAt(value, parts, index) {
125
+ function redactAt(value, parts, index, compiled) {
53
126
  if (index >= parts.length) {
54
- return FILTERED;
127
+ return compiled.replace(value);
55
128
  }
56
129
  if (typeof value !== "object" || value === null) {
57
130
  return value;
58
131
  }
59
132
  const key = String(parts[index]);
60
133
  if (Array.isArray(value)) {
61
- return redactArray(value, parts, index, key);
134
+ return redactArray(value, parts, index, key, compiled);
62
135
  }
63
- return redactObject(plainObjectFrom(value), parts, index, key);
136
+ return redactObject(plainObjectFrom(value), parts, index, key, compiled);
64
137
  }
65
- /** Apply precompiled redact paths to a value tree. */
66
- export function applyCompiledRedactPaths(value, paths) {
138
+ function applyKeys(value, compiled) {
139
+ if (compiled.keys.size === 0) {
140
+ return value;
141
+ }
142
+ if (typeof value !== "object" || value === null) {
143
+ return value;
144
+ }
145
+ if (Array.isArray(value)) {
146
+ const items = value;
147
+ return items.map((item) => applyKeys(item, compiled));
148
+ }
149
+ const source = plainObjectFrom(value);
150
+ const out = {};
151
+ for (const key of Object.keys(source)) {
152
+ if (keyMatches(key, compiled)) {
153
+ const replaced = compiled.replace(source[key]);
154
+ if (replaced !== REMOVED) {
155
+ out[key] = replaced;
156
+ }
157
+ continue;
158
+ }
159
+ out[key] = applyKeys(source[key], compiled);
160
+ }
161
+ return out;
162
+ }
163
+ /** Apply a compiled redact plan (paths then keys). */
164
+ export function applyCompiledRedact(value, compiled) {
67
165
  let current = value;
68
- for (const parts of paths) {
69
- current = redactAt(current, parts, 0);
166
+ for (const parts of compiled.paths) {
167
+ current = redactAt(current, parts, 0, compiled);
70
168
  }
71
- return current;
169
+ return applyKeys(current, compiled);
170
+ }
171
+ /** Apply precompiled path list with default `[FILTERED]` censor. */
172
+ export function applyCompiledRedactPaths(value, paths) {
173
+ return applyCompiledRedact(value, {
174
+ paths,
175
+ keys: new Set(),
176
+ caseInsensitive: false,
177
+ replace: () => FILTERED,
178
+ });
72
179
  }
73
- /** Apply string paths (compiles each call). Prefer compileRedactPaths on hot paths. */
180
+ /** Apply string paths (compiles each call). Prefer compileRedact on hot paths. */
74
181
  export function applyRedactPaths(value, paths) {
75
- return applyCompiledRedactPaths(value, compileRedactPaths(paths));
182
+ return applyCompiledRedact(value, compileRedact(paths));
183
+ }
184
+ /** True when the compiled plan has any paths or keys. */
185
+ export function hasRedactWork(compiled) {
186
+ return compiled.paths.length > 0 || compiled.keys.size > 0;
76
187
  }
77
188
  //# sourceMappingURL=redact.js.map
@@ -1,4 +1,4 @@
1
- import { type CompiledRedactPath } from "./redact.js";
1
+ import { type CompiledRedact, type CompiledRedactPath } from "./redact.js";
2
2
  import type { ContextProvider, FieldSerializer, Level, LogMessage, LogRecord } from "./types.js";
3
3
  export type { PerformanceLike } from "./timestamp.js";
4
4
  export { currentTimestamp, defaultPerformance, formatTimestamp, } from "./timestamp.js";
@@ -18,7 +18,8 @@ export type BuildLineInput = {
18
18
  preparedTags?: boolean;
19
19
  clock?: () => Date;
20
20
  redact?: readonly string[];
21
- compiledRedact?: readonly CompiledRedactPath[];
21
+ /** Precompiled path list (RFC-0002) or full plan (RFC-0005). */
22
+ compiledRedact?: readonly CompiledRedactPath[] | CompiledRedact;
22
23
  serializers?: Readonly<Record<string, FieldSerializer>>;
23
24
  contextProvider?: ContextProvider;
24
25
  };
package/dist/serialize.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { peekLogStore } from "./context.js";
2
2
  import { isPlainObject, readObjectField } from "./objects.js";
3
- import { applyCompiledRedactPaths, applyRedactPaths } from "./redact.js";
3
+ import { applyCompiledRedact, applyCompiledRedactPaths, applyRedactPaths, hasRedactWork, } from "./redact.js";
4
4
  import { prepareTags, sanitizeError, sanitizeHash, sanitizeString, sanitizeValue, } from "./sanitize.js";
5
5
  import { severityName } from "./severity.js";
6
6
  import { currentTimestamp } from "./timestamp.js";
@@ -48,6 +48,9 @@ function mergeUserFields(payload, fields, alreadySanitized = false) {
48
48
  }
49
49
  return payload;
50
50
  }
51
+ function isCompiledRedactPlan(value) {
52
+ return !Array.isArray(value) && typeof value === "object" && value !== null && "replace" in value;
53
+ }
51
54
  function applySerializers(payload, serializers) {
52
55
  for (const key of Object.keys(serializers)) {
53
56
  if (!Object.hasOwn(payload, key)) {
@@ -146,9 +149,15 @@ export function buildRecord(input) {
146
149
  applySerializers(payload, input.serializers);
147
150
  }
148
151
  }
149
- if (input.compiledRedact && input.compiledRedact.length > 0) {
150
- const redacted = applyCompiledRedactPaths(payload, input.compiledRedact);
151
- if (isPlainObject(redacted)) {
152
+ if (input.compiledRedact) {
153
+ const redacted = isCompiledRedactPlan(input.compiledRedact)
154
+ ? hasRedactWork(input.compiledRedact)
155
+ ? applyCompiledRedact(payload, input.compiledRedact)
156
+ : payload
157
+ : input.compiledRedact.length > 0
158
+ ? applyCompiledRedactPaths(payload, input.compiledRedact)
159
+ : payload;
160
+ if (redacted !== payload && isPlainObject(redacted)) {
152
161
  for (const key of Object.keys(payload)) {
153
162
  delete payload[key];
154
163
  }
package/dist/types.d.ts CHANGED
@@ -14,6 +14,25 @@ export type Sink = {
14
14
  export type FieldSerializer = (value: unknown) => unknown;
15
15
  /** Optional ambient enrichment at log time (e.g. trace ids). Must not throw; throws take never-raise fallback. */
16
16
  export type ContextProvider = () => Record<string, unknown> | undefined;
17
+ /**
18
+ * How matched redact leaves are replaced.
19
+ * `filtered` writes `[FILTERED]`; `remove` drops object keys (array slots become `null`);
20
+ * `hash` writes a non-cryptographic `[label:hex8]` correlation token.
21
+ */
22
+ export type RedactCensor = "filtered" | "remove" | "hash" | ((value: unknown) => unknown);
23
+ /** Path and key redaction options (RFC-0005). Array form remains path-only with `filtered`. */
24
+ export type RedactOptions = {
25
+ /** Dot paths; segment `*` matches one key or index. */
26
+ paths?: readonly string[];
27
+ /** Object key names matched anywhere in the record tree. */
28
+ keys?: readonly string[];
29
+ /** When true, `keys` matching ignores case. Default false. */
30
+ caseInsensitive?: boolean;
31
+ /** Replacement mode. Default `filtered`. */
32
+ censor?: RedactCensor;
33
+ /** Label inside hash tokens. Default `hash`. */
34
+ hashLabel?: string;
35
+ };
17
36
  /** Construction options for {@link Logger}. */
18
37
  export type LoggerOptions = {
19
38
  /** Minimum level to emit. Default `info`. */
@@ -26,8 +45,11 @@ export type LoggerOptions = {
26
45
  clock?: () => Date;
27
46
  /** Sticky tags applied to every line from this logger. */
28
47
  tags?: readonly string[];
29
- /** Dot paths to filter (config-time only). Segment `*` matches one key or index. */
30
- redact?: readonly string[];
48
+ /**
49
+ * Dot paths (RFC-0002) or {@link RedactOptions} with keys and censor modes (RFC-0005).
50
+ * Config-time only. Segment `*` matches one key or index.
51
+ */
52
+ redact?: readonly string[] | RedactOptions;
31
53
  /** Root field name to serializer. */
32
54
  serializers?: Readonly<Record<string, FieldSerializer>>;
33
55
  /** Merged under `context` after ALS ambient context; call-site context wins. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amkisko/json-logging",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Structured JSON logging with ActiveSupport-style tagged and withContext helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",