@raypx-kit/logger 0.0.0-alpha.0 → 0.0.0-alpha.2

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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # @raypx-kit/logger
2
2
 
3
+ ## 0.0.0-alpha.2
4
+
5
+ ### Major Changes
6
+
7
+ - a6af9fe: Raise the minimum supported Node.js version from 22.14.0 to 22.21.0. Update
8
+ package engine declarations and library build targets to the new floor, and
9
+ verify installed consumers on Node 22.21.0. Upgrade older Node installations
10
+ before installing these releases. The repository development pin remains 24.22.0.
11
+
12
+ ## 0.0.0-alpha.1
13
+
14
+ ### Patch Changes
15
+
16
+ - 5e0caaf: Add `@raypx-kit/logger/safe`, an explicit field policy for drains that removes credentials, personal data, messages, causes and stacks and bounds each event, and `@raypx-kit/logger/stream`, an ordered JSON-lines drain for stderr or another writable.
17
+
3
18
  ## 0.0.0-alpha.0
4
19
 
5
20
  Initial Raypx Kit prerelease.
package/README.md CHANGED
@@ -11,7 +11,7 @@ logger through every function.
11
11
  bun add @raypx-kit/logger
12
12
  ```
13
13
 
14
- ESM; Node 22.14+ or Bun. evlog is an external runtime dependency (`^2.33.1`), not
14
+ ESM; Node 22.21+ or Bun. evlog is an external runtime dependency (`^2.33.1`), not
15
15
  bundled or copied. Its framework adapters and their optional peers are not
16
16
  required. The Node-only context entry uses `node:async_hooks`; the root and
17
17
  pipeline entries do not import it.
@@ -50,6 +50,8 @@ Importing this package does not initialize logging.
50
50
  | `@raypx-kit/logger` | `initLogger`, `log`, `createLogger`, `createRequestLogger`, `createError`, `EvlogError`, `parseError`, `defineEvlog`, `toLoggerConfig`; their configuration, event, logger, error, sampling and drain types |
51
51
  | `@raypx-kit/logger/context` | `runWithLogger`, `getLogger`, `useLogger` for Node and Bun asynchronous context |
52
52
  | `@raypx-kit/logger/pipeline` | `createDrainPipeline`, `DrainPipelineOptions`, `PipelineDrainFn` |
53
+ | `@raypx-kit/logger/safe` | `withEventPolicy`, `createEventPolicy`, `safeError`, `DEFAULT_SENSITIVE_KEYS` and their types; no Node APIs, so it also runs on Workers |
54
+ | `@raypx-kit/logger/stream` | `createStreamDrain` for ordered JSON lines on stderr or another Node writable |
53
55
 
54
56
  Core functions retain evlog's native types and behavior. `defineEvlog` describes
55
57
  shared configuration; pass `toLoggerConfig(config)` to `initLogger` when using a
@@ -130,6 +132,47 @@ For individual messages, use structured objects, for example
130
132
  `log.info({ action: 'worker.started' })`. This keeps events on the same drain and
131
133
  redaction path. evlog's pretty string/tag form bypasses that path.
132
134
 
135
+ ## Allowed fields and redaction
136
+
137
+ Applications that must never log credentials, personal data or file contents can
138
+ wrap their drain with an explicit field policy. Only the envelope (`timestamp`,
139
+ `level`, `service`, `environment`) and the listed fields leave the process:
140
+
141
+ ```ts
142
+ import { initLogger } from "@raypx-kit/logger";
143
+ import { withEventPolicy } from "@raypx-kit/logger/safe";
144
+ import { createStreamDrain } from "@raypx-kit/logger/stream";
145
+
146
+ const output = createStreamDrain(); // JSON lines on stderr
147
+ initLogger({
148
+ env: { service: "cli", environment: "production" },
149
+ silent: true,
150
+ drain: withEventPolicy(output, {
151
+ fields: ["operation", "requestId", "status", "durationMs", "error", "rpc"],
152
+ shapes: { rpc: ["system", "method"] },
153
+ }),
154
+ });
155
+
156
+ // Before exit:
157
+ await output.close();
158
+ ```
159
+
160
+ - Values of sensitive keys (passwords, tokens, cookies, headers, bodies, messages,
161
+ stacks, paths, commands, URLs, e-mail addresses and similar) are replaced at any
162
+ depth; strings are shortened and URLs, bearer tokens, JWTs and e-mail addresses
163
+ inside them are masked. Pass `sensitiveKeys` to replace the default pattern.
164
+ - `error` keeps only a standard error name and a machine `code`; messages, causes
165
+ and stacks never leave the process.
166
+ - `shapes` keeps selected string properties of framework objects.
167
+ - Each event is limited to `maxEventBytes` (16 KiB by default); fields beyond the
168
+ limit are dropped in the order they are listed. Getters, `toJSON`, cycles and
169
+ hostile proxies are never invoked or followed.
170
+ - Request metadata and headers in the drain context are not forwarded.
171
+
172
+ `createStreamDrain` writes in order, never ends a stream it did not create, drops
173
+ events beyond `maxPending` (1024) and reports write or serialization failures once
174
+ from `flush()` or `close()`, never from the code that emitted the event.
175
+
133
176
  ## Drains, batching and shutdown
134
177
 
135
178
  ```ts
@@ -1,8 +1,6 @@
1
1
  import { AuditableLogger } from "evlog";
2
- //#region src/context.d.ts
3
2
  /** Run work with an existing logger; the caller retains ownership of emit(). */
4
3
  export declare function runWithLogger<TResult>(logger: AuditableLogger<object>, callback: () => TResult): TResult;
5
4
  export declare function getLogger<T extends object = Record<string, unknown>>(): AuditableLogger<T> | undefined;
6
5
  export declare const useLogger: <T extends object = Record<string, unknown>>() => AuditableLogger<T>;
7
- //#endregion
8
6
  //# sourceMappingURL=context.d.mts.map
package/dist/context.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createLoggerStorage } from "evlog/toolkit/storage";
2
- //#region src/context.ts
2
+ // src/context.ts
3
3
  const context = createLoggerStorage("logger context. Wrap the operation in runWithLogger(logger, callback).", "@raypx-kit/logger/context");
4
4
  /** Run work with an existing logger; the caller retains ownership of emit(). */
5
5
  function runWithLogger(logger, callback) {
@@ -9,7 +9,6 @@ function getLogger() {
9
9
  return context.storage.getStore() === void 0 ? void 0 : context.useLogger();
10
10
  }
11
11
  const useLogger = context.useLogger;
12
- //#endregion
13
12
  export { getLogger, runWithLogger, useLogger };
14
13
 
15
14
  //# sourceMappingURL=context.mjs.map
@@ -0,0 +1,53 @@
1
+ import { DrainContext } from "evlog";
2
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ /** A drained event after the policy: the fixed envelope plus allowed, cleaned fields. */
6
+ export type SafeEvent = Readonly<Record<string, JsonValue>> & {
7
+ readonly timestamp: string;
8
+ readonly level: string;
9
+ readonly service: string;
10
+ readonly environment: string;
11
+ };
12
+ export type SafeDrain = (context: {
13
+ event: SafeEvent;
14
+ }) => void | Promise<void>;
15
+ export interface EventPolicyOptions {
16
+ /** Top-level fields that may leave the process. Everything else is dropped. */
17
+ fields: readonly string[];
18
+ /**
19
+ * Allowed fields that keep only the listed string properties, for framework
20
+ * objects such as `{ rpc: ["system", "method"] }`.
21
+ */
22
+ shapes?: Readonly<Record<string, readonly string[]>>;
23
+ /** Upper bound for one serialized event in UTF-8 bytes (default 16 KiB). */
24
+ maxEventBytes?: number;
25
+ /**
26
+ * Field names whose values are always replaced, at any depth. Names are compared
27
+ * after removing non-alphanumerics and lowercasing. Defaults to
28
+ * {@link DEFAULT_SENSITIVE_KEYS}.
29
+ */
30
+ sensitiveKeys?: RegExp;
31
+ }
32
+ /** Credentials, personal data, request material, process output and free text. */
33
+ export declare const DEFAULT_SENSITIVE_KEYS: RegExp;
34
+ /**
35
+ * Reduces any error, including evlog's serialized error field, to a standard name and
36
+ * an optional machine code. Messages, causes and stacks are never kept.
37
+ */
38
+ export declare function safeError(error: unknown): {
39
+ name: string;
40
+ code?: string | number;
41
+ };
42
+ /**
43
+ * Returns a function that projects a raw event to its safe form: the envelope, then
44
+ * allowed fields in order until the byte limit. Hostile values cannot throw out of it.
45
+ */
46
+ export declare function createEventPolicy({ fields, shapes, maxEventBytes, sensitiveKeys }: EventPolicyOptions): (event: unknown) => SafeEvent;
47
+ /**
48
+ * Wraps a drain so it only ever receives policy-projected events. Request metadata
49
+ * and headers from the drain context are never forwarded. A failing projection
50
+ * drops the event instead of interrupting the caller.
51
+ */
52
+ export declare function withEventPolicy(drain: SafeDrain, options: EventPolicyOptions): (context: DrainContext) => void | Promise<void>;
53
+ //# sourceMappingURL=safe.d.mts.map
package/dist/safe.mjs ADDED
@@ -0,0 +1,180 @@
1
+ // src/safe.ts
2
+ const REDACTED = "[REDACTED]";
3
+ const ENVELOPE = [
4
+ "timestamp",
5
+ "level",
6
+ "service",
7
+ "environment"
8
+ ];
9
+ const LEVELS = /* @__PURE__ */ new Set([
10
+ "trace",
11
+ "debug",
12
+ "info",
13
+ "warn",
14
+ "error",
15
+ "fatal"
16
+ ]);
17
+ const FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
18
+ "__proto__",
19
+ "constructor",
20
+ "prototype",
21
+ "toJSON"
22
+ ]);
23
+ const ERROR_NAMES = /* @__PURE__ */ new Set([
24
+ "Error",
25
+ "TypeError",
26
+ "RangeError",
27
+ "ReferenceError",
28
+ "SyntaxError",
29
+ "URIError",
30
+ "EvalError",
31
+ "AggregateError",
32
+ "AbortError",
33
+ "TimeoutError"
34
+ ]);
35
+ /** Credentials, personal data, request material, process output and free text. */
36
+ const DEFAULT_SENSITIVE_KEYS = /^(path|input|text)$|password|passwd|secret|token|authorization|cookie|credential|apikey|privatekey|email|username|headers|payload|body|content|output|stdout|stderr|stack|cause|message|filename|filepath|directory|command|argv|args|cwd|query|url|uri/;
37
+ /** Reads data properties only; the policy never invokes getters or toJSON. */
38
+ function ownData(value, key) {
39
+ if (value === null || typeof value !== "object") return void 0;
40
+ try {
41
+ return Object.getOwnPropertyDescriptor(value, key)?.value;
42
+ } catch {
43
+ return;
44
+ }
45
+ }
46
+ function cleanString(value) {
47
+ return value.slice(0, 256).replace(/https?:\/\/[^\s]+/gi, REDACTED).replace(/\bBearer\s+[^\s,;]+/gi, REDACTED).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, REDACTED).replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, REDACTED).replace(/\b(password|token|secret|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, REDACTED).replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 256);
48
+ }
49
+ /**
50
+ * Reduces any error, including evlog's serialized error field, to a standard name and
51
+ * an optional machine code. Messages, causes and stacks are never kept.
52
+ */
53
+ function safeError(error) {
54
+ try {
55
+ let name = ownData(error, "name");
56
+ if (name === void 0 && error instanceof Error) {
57
+ if (error instanceof TypeError) name = "TypeError";
58
+ else if (error instanceof RangeError) name = "RangeError";
59
+ else if (error instanceof SyntaxError) name = "SyntaxError";
60
+ else if (error instanceof ReferenceError) name = "ReferenceError";
61
+ }
62
+ const result = { name: typeof name === "string" && ERROR_NAMES.has(name) ? name : "Error" };
63
+ const code = ownData(error, "code");
64
+ if (typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)) result.code = code;
65
+ else if (typeof code === "number" && Number.isSafeInteger(code)) result.code = code;
66
+ return result;
67
+ } catch {
68
+ return { name: "Error" };
69
+ }
70
+ }
71
+ /**
72
+ * Returns a function that projects a raw event to its safe form: the envelope, then
73
+ * allowed fields in order until the byte limit. Hostile values cannot throw out of it.
74
+ */
75
+ function createEventPolicy({ fields, shapes = {}, maxEventBytes = 16384, sensitiveKeys = DEFAULT_SENSITIVE_KEYS }) {
76
+ const sensitive = (key) => sensitiveKeys.test(key.replace(/[^a-z0-9]/gi, "").toLowerCase());
77
+ const allowed = [...new Set(fields)].filter((key) => key.length <= 64 && !FORBIDDEN_KEYS.has(key) && !ENVELOPE.includes(key));
78
+ const encoder = new TextEncoder();
79
+ function clean(value, depth, seen, budget) {
80
+ if (budget.remaining-- <= 0) return "[SIZE_LIMIT]";
81
+ try {
82
+ if (value === null || typeof value === "boolean") return value;
83
+ if (typeof value === "string") return cleanString(value);
84
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
85
+ if (typeof value !== "object") return void 0;
86
+ if (value instanceof Error) return safeError(value);
87
+ if (depth >= 4) return "[DEPTH_LIMIT]";
88
+ if (seen.has(value)) return "[CIRCULAR]";
89
+ seen.add(value);
90
+ try {
91
+ if (Array.isArray(value)) {
92
+ const result = [];
93
+ const length = ownData(value, "length");
94
+ const count = Math.min(typeof length === "number" ? length : 0, 16);
95
+ for (let index = 0; index < count && budget.remaining > 0; index++) {
96
+ const item = clean(ownData(value, String(index)), depth + 1, seen, budget);
97
+ if (item !== void 0) result.push(item);
98
+ }
99
+ return result;
100
+ }
101
+ const result = {};
102
+ for (const key of Object.keys(value).slice(0, 32)) {
103
+ if (budget.remaining <= 0) break;
104
+ if (key.length > 64 || FORBIDDEN_KEYS.has(key)) continue;
105
+ if (sensitive(key)) {
106
+ result[key] = REDACTED;
107
+ continue;
108
+ }
109
+ const child = clean(ownData(value, key), depth + 1, seen, budget);
110
+ if (child !== void 0) result[key] = child;
111
+ }
112
+ return result;
113
+ } catch {
114
+ return "[UNAVAILABLE]";
115
+ } finally {
116
+ seen.delete(value);
117
+ }
118
+ } catch {
119
+ return "[UNAVAILABLE]";
120
+ }
121
+ }
122
+ return (event) => {
123
+ const text = (key, fallback) => {
124
+ const value = ownData(event, key);
125
+ return typeof value === "string" ? cleanString(value).slice(0, 80) : fallback;
126
+ };
127
+ const level = ownData(event, "level");
128
+ const result = {
129
+ timestamp: text("timestamp", (/* @__PURE__ */ new Date()).toISOString()),
130
+ level: typeof level === "string" && LEVELS.has(level) ? level : "info",
131
+ service: text("service", "unknown"),
132
+ environment: text("environment", "unknown")
133
+ };
134
+ let bytes = encoder.encode(JSON.stringify(result)).byteLength;
135
+ const budget = { remaining: 256 };
136
+ for (const key of allowed) {
137
+ if (budget.remaining <= 0) break;
138
+ budget.remaining -= 1;
139
+ const value = ownData(event, key);
140
+ if (value === void 0) continue;
141
+ let projected;
142
+ if (sensitive(key)) projected = REDACTED;
143
+ else if (key === "error") projected = safeError(value);
144
+ else if (Object.hasOwn(shapes, key)) {
145
+ const shaped = {};
146
+ for (const field of shapes[key] ?? []) {
147
+ const entry = ownData(value, field);
148
+ if (typeof entry === "string" && !sensitive(field)) shaped[field] = cleanString(entry);
149
+ }
150
+ projected = shaped;
151
+ } else projected = clean(value, 0, /* @__PURE__ */ new WeakSet(), budget);
152
+ if (projected === void 0) continue;
153
+ const size = encoder.encode(JSON.stringify({ [key]: projected })).byteLength;
154
+ if (bytes + size > maxEventBytes) continue;
155
+ result[key] = projected;
156
+ bytes += size;
157
+ }
158
+ return Object.freeze(result);
159
+ };
160
+ }
161
+ /**
162
+ * Wraps a drain so it only ever receives policy-projected events. Request metadata
163
+ * and headers from the drain context are never forwarded. A failing projection
164
+ * drops the event instead of interrupting the caller.
165
+ */
166
+ function withEventPolicy(drain, options) {
167
+ const project = createEventPolicy(options);
168
+ return (context) => {
169
+ let event;
170
+ try {
171
+ event = project(ownData(context, "event"));
172
+ } catch {
173
+ return;
174
+ }
175
+ return drain({ event });
176
+ };
177
+ }
178
+ export { DEFAULT_SENSITIVE_KEYS, createEventPolicy, safeError, withEventPolicy };
179
+
180
+ //# sourceMappingURL=safe.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safe.mjs","names":[],"sources":["../src/safe.ts"],"sourcesContent":["import type { DrainContext } from \"evlog\";\n\nexport type JsonValue =\n | null\n | boolean\n | number\n | string\n | JsonValue[]\n | { [key: string]: JsonValue };\n\n/** A drained event after the policy: the fixed envelope plus allowed, cleaned fields. */\nexport type SafeEvent = Readonly<Record<string, JsonValue>> & {\n readonly timestamp: string;\n readonly level: string;\n readonly service: string;\n readonly environment: string;\n};\n\nexport type SafeDrain = (context: { event: SafeEvent }) => void | Promise<void>;\n\nexport interface EventPolicyOptions {\n /** Top-level fields that may leave the process. Everything else is dropped. */\n fields: readonly string[];\n /**\n * Allowed fields that keep only the listed string properties, for framework\n * objects such as `{ rpc: [\"system\", \"method\"] }`.\n */\n shapes?: Readonly<Record<string, readonly string[]>>;\n /** Upper bound for one serialized event in UTF-8 bytes (default 16 KiB). */\n maxEventBytes?: number;\n /**\n * Field names whose values are always replaced, at any depth. Names are compared\n * after removing non-alphanumerics and lowercasing. Defaults to\n * {@link DEFAULT_SENSITIVE_KEYS}.\n */\n sensitiveKeys?: RegExp;\n}\n\nconst REDACTED = \"[REDACTED]\";\nconst ENVELOPE = [\"timestamp\", \"level\", \"service\", \"environment\"] as const;\nconst LEVELS = new Set([\"trace\", \"debug\", \"info\", \"warn\", \"error\", \"fatal\"]);\nconst FORBIDDEN_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\", \"toJSON\"]);\nconst ERROR_NAMES = new Set([\n \"Error\",\n \"TypeError\",\n \"RangeError\",\n \"ReferenceError\",\n \"SyntaxError\",\n \"URIError\",\n \"EvalError\",\n \"AggregateError\",\n \"AbortError\",\n \"TimeoutError\",\n]);\n\n/** Credentials, personal data, request material, process output and free text. */\nexport const DEFAULT_SENSITIVE_KEYS =\n /^(path|input|text)$|password|passwd|secret|token|authorization|cookie|credential|apikey|privatekey|email|username|headers|payload|body|content|output|stdout|stderr|stack|cause|message|filename|filepath|directory|command|argv|args|cwd|query|url|uri/;\n\n/** Reads data properties only; the policy never invokes getters or toJSON. */\nfunction ownData(value: unknown, key: string): unknown {\n if (value === null || typeof value !== \"object\") return undefined;\n try {\n return Object.getOwnPropertyDescriptor(value, key)?.value;\n } catch {\n return undefined;\n }\n}\n\nfunction cleanString(value: string): string {\n return (\n value\n .slice(0, 256)\n .replace(/https?:\\/\\/[^\\s]+/gi, REDACTED)\n .replace(/\\bBearer\\s+[^\\s,;]+/gi, REDACTED)\n .replace(/\\beyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\b/g, REDACTED)\n .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi, REDACTED)\n .replace(/\\b(password|token|secret|api[_-]?key)\\s*[:=]\\s*[^\\s,;]+/gi, REDACTED)\n // oxlint-disable-next-line no-control-regex -- Control characters never reach log output.\n .replace(/[\\u0000-\\u001f\\u007f]/g, \" \")\n .slice(0, 256)\n );\n}\n\n/**\n * Reduces any error, including evlog's serialized error field, to a standard name and\n * an optional machine code. Messages, causes and stacks are never kept.\n */\nexport function safeError(error: unknown): { name: string; code?: string | number } {\n try {\n let name = ownData(error, \"name\");\n if (name === undefined && error instanceof Error) {\n if (error instanceof TypeError) name = \"TypeError\";\n else if (error instanceof RangeError) name = \"RangeError\";\n else if (error instanceof SyntaxError) name = \"SyntaxError\";\n else if (error instanceof ReferenceError) name = \"ReferenceError\";\n }\n const result: { name: string; code?: string | number } = {\n name: typeof name === \"string\" && ERROR_NAMES.has(name) ? name : \"Error\",\n };\n const code = ownData(error, \"code\");\n if (typeof code === \"string\" && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)) result.code = code;\n else if (typeof code === \"number\" && Number.isSafeInteger(code)) result.code = code;\n return result;\n } catch {\n return { name: \"Error\" };\n }\n}\n\n/**\n * Returns a function that projects a raw event to its safe form: the envelope, then\n * allowed fields in order until the byte limit. Hostile values cannot throw out of it.\n */\nexport function createEventPolicy({\n fields,\n shapes = {},\n maxEventBytes = 16_384,\n sensitiveKeys = DEFAULT_SENSITIVE_KEYS,\n}: EventPolicyOptions): (event: unknown) => SafeEvent {\n const sensitive = (key: string) =>\n sensitiveKeys.test(key.replace(/[^a-z0-9]/gi, \"\").toLowerCase());\n const allowed = [...new Set(fields)].filter(\n (key) =>\n key.length <= 64 &&\n !FORBIDDEN_KEYS.has(key) &&\n !(ENVELOPE as readonly string[]).includes(key),\n );\n const encoder = new TextEncoder();\n\n function clean(\n value: unknown,\n depth: number,\n seen: WeakSet<object>,\n budget: { remaining: number },\n ): JsonValue | undefined {\n if (budget.remaining-- <= 0) return \"[SIZE_LIMIT]\";\n try {\n if (value === null || typeof value === \"boolean\") return value;\n if (typeof value === \"string\") return cleanString(value);\n if (typeof value === \"number\") return Number.isFinite(value) ? value : undefined;\n if (typeof value !== \"object\") return undefined;\n if (value instanceof Error) return safeError(value);\n if (depth >= 4) return \"[DEPTH_LIMIT]\";\n if (seen.has(value)) return \"[CIRCULAR]\";\n seen.add(value);\n try {\n if (Array.isArray(value)) {\n const result: JsonValue[] = [];\n const length = ownData(value, \"length\");\n const count = Math.min(typeof length === \"number\" ? length : 0, 16);\n for (let index = 0; index < count && budget.remaining > 0; index++) {\n const item = clean(ownData(value, String(index)), depth + 1, seen, budget);\n if (item !== undefined) result.push(item);\n }\n return result;\n }\n const result: Record<string, JsonValue> = {};\n for (const key of Object.keys(value).slice(0, 32)) {\n if (budget.remaining <= 0) break;\n if (key.length > 64 || FORBIDDEN_KEYS.has(key)) continue;\n if (sensitive(key)) {\n result[key] = REDACTED;\n continue;\n }\n const child = clean(ownData(value, key), depth + 1, seen, budget);\n if (child !== undefined) result[key] = child;\n }\n return result;\n } catch {\n return \"[UNAVAILABLE]\";\n } finally {\n seen.delete(value);\n }\n } catch {\n return \"[UNAVAILABLE]\";\n }\n }\n\n return (event) => {\n const text = (key: string, fallback: string) => {\n const value = ownData(event, key);\n return typeof value === \"string\" ? cleanString(value).slice(0, 80) : fallback;\n };\n const level = ownData(event, \"level\");\n const result: Record<string, JsonValue> = {\n timestamp: text(\"timestamp\", new Date().toISOString()),\n level: typeof level === \"string\" && LEVELS.has(level) ? level : \"info\",\n service: text(\"service\", \"unknown\"),\n environment: text(\"environment\", \"unknown\"),\n };\n let bytes = encoder.encode(JSON.stringify(result)).byteLength;\n const budget = { remaining: 256 };\n for (const key of allowed) {\n if (budget.remaining <= 0) break;\n budget.remaining -= 1;\n const value = ownData(event, key);\n if (value === undefined) continue;\n let projected: JsonValue | undefined;\n if (sensitive(key)) projected = REDACTED;\n else if (key === \"error\") projected = safeError(value);\n else if (Object.hasOwn(shapes, key)) {\n const shaped: Record<string, JsonValue> = {};\n for (const field of shapes[key] ?? []) {\n const entry = ownData(value, field);\n if (typeof entry === \"string\" && !sensitive(field)) shaped[field] = cleanString(entry);\n }\n projected = shaped;\n } else projected = clean(value, 0, new WeakSet(), budget);\n if (projected === undefined) continue;\n const size = encoder.encode(JSON.stringify({ [key]: projected })).byteLength;\n if (bytes + size > maxEventBytes) continue;\n result[key] = projected;\n bytes += size;\n }\n return Object.freeze(result) as SafeEvent;\n };\n}\n\n/**\n * Wraps a drain so it only ever receives policy-projected events. Request metadata\n * and headers from the drain context are never forwarded. A failing projection\n * drops the event instead of interrupting the caller.\n */\nexport function withEventPolicy(\n drain: SafeDrain,\n options: EventPolicyOptions,\n): (context: DrainContext) => void | Promise<void> {\n const project = createEventPolicy(options);\n return (context) => {\n let event: SafeEvent;\n try {\n event = project(ownData(context, \"event\"));\n } catch {\n return;\n }\n return drain({ event });\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import { Writable } from "node:stream";
2
+ export interface StreamDrainOptions {
3
+ /** Defaults to `process.stderr`. A caller-owned stream is flushed but never ended. */
4
+ stream?: Writable;
5
+ /** Events waiting for the stream before new ones are dropped (default 1024). */
6
+ maxPending?: number;
7
+ }
8
+ export interface StreamDrain {
9
+ (context: {
10
+ event: unknown;
11
+ }): Promise<void> | undefined;
12
+ /** Waits for written events; rejects once if any write or serialization failed. */
13
+ flush(): Promise<void>;
14
+ /** Stops accepting events, flushes and detaches from the stream. */
15
+ close(): Promise<void>;
16
+ }
17
+ /**
18
+ * Writes one JSON line per event, in order. Failures are recorded for flush/close
19
+ * instead of being thrown into the code that emitted the event.
20
+ */
21
+ export declare function createStreamDrain({ stream, maxPending }?: StreamDrainOptions): StreamDrain;
22
+ //# sourceMappingURL=stream.d.mts.map
@@ -0,0 +1,62 @@
1
+ // src/stream.ts
2
+ /**
3
+ * Writes one JSON line per event, in order. Failures are recorded for flush/close
4
+ * instead of being thrown into the code that emitted the event.
5
+ */
6
+ function createStreamDrain({ stream = process.stderr, maxPending = 1024 } = {}) {
7
+ let tail = Promise.resolve();
8
+ let pending = 0;
9
+ let failed = false;
10
+ let closed = false;
11
+ const onError = () => {
12
+ failed = true;
13
+ };
14
+ stream.on("error", onError);
15
+ const drain = ((context) => {
16
+ if (closed) return void 0;
17
+ if (pending >= maxPending) {
18
+ failed = true;
19
+ return;
20
+ }
21
+ let line;
22
+ try {
23
+ line = `${JSON.stringify(context.event)}\n`;
24
+ } catch {
25
+ failed = true;
26
+ return;
27
+ }
28
+ pending += 1;
29
+ tail = tail.then(() => new Promise((resolve) => {
30
+ const done = (error) => {
31
+ if (error) failed = true;
32
+ pending -= 1;
33
+ resolve();
34
+ };
35
+ try {
36
+ stream.write(line, done);
37
+ } catch {
38
+ done(/* @__PURE__ */ new Error("write failed"));
39
+ }
40
+ }));
41
+ return tail;
42
+ });
43
+ drain.flush = async () => {
44
+ await tail;
45
+ if (failed) {
46
+ failed = false;
47
+ throw new Error("Log stream drain failed");
48
+ }
49
+ };
50
+ drain.close = async () => {
51
+ closed = true;
52
+ try {
53
+ await drain.flush();
54
+ } finally {
55
+ stream.off("error", onError);
56
+ }
57
+ };
58
+ return drain;
59
+ }
60
+ export { createStreamDrain };
61
+
62
+ //# sourceMappingURL=stream.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.mjs","names":[],"sources":["../src/stream.ts"],"sourcesContent":["import type { Writable } from \"node:stream\";\n\nexport interface StreamDrainOptions {\n /** Defaults to `process.stderr`. A caller-owned stream is flushed but never ended. */\n stream?: Writable;\n /** Events waiting for the stream before new ones are dropped (default 1024). */\n maxPending?: number;\n}\n\nexport interface StreamDrain {\n (context: { event: unknown }): Promise<void> | undefined;\n /** Waits for written events; rejects once if any write or serialization failed. */\n flush(): Promise<void>;\n /** Stops accepting events, flushes and detaches from the stream. */\n close(): Promise<void>;\n}\n\n/**\n * Writes one JSON line per event, in order. Failures are recorded for flush/close\n * instead of being thrown into the code that emitted the event.\n */\nexport function createStreamDrain({\n stream = process.stderr,\n maxPending = 1024,\n}: StreamDrainOptions = {}): StreamDrain {\n let tail: Promise<void> = Promise.resolve();\n let pending = 0;\n let failed = false;\n let closed = false;\n const onError = () => {\n failed = true;\n };\n stream.on(\"error\", onError);\n\n const drain = ((context) => {\n if (closed) return undefined;\n if (pending >= maxPending) {\n failed = true;\n return undefined;\n }\n let line: string;\n try {\n line = `${JSON.stringify(context.event)}\\n`;\n } catch {\n failed = true;\n return undefined;\n }\n pending += 1;\n tail = tail.then(\n () =>\n new Promise<void>((resolve) => {\n const done = (error?: Error | null) => {\n if (error) failed = true;\n pending -= 1;\n resolve();\n };\n try {\n stream.write(line, done);\n } catch {\n done(new Error(\"write failed\"));\n }\n }),\n );\n return tail;\n }) as StreamDrain;\n\n drain.flush = async () => {\n await tail;\n if (failed) {\n failed = false;\n throw new Error(\"Log stream drain failed\");\n }\n };\n drain.close = async () => {\n closed = true;\n try {\n await drain.flush();\n } finally {\n stream.off(\"error\", onError);\n }\n };\n return drain;\n}\n"],"mappings":";;;;;AAqBA,SAAgB,kBAAkB,EAChC,SAAS,QAAQ,QACjB,aAAa,SACS,CAAC,GAAgB;CACvC,IAAI,OAAsB,QAAQ,QAAQ;CAC1C,IAAI,UAAU;CACd,IAAI,SAAS;CACb,IAAI,SAAS;CACb,MAAM,gBAAgB;EACpB,SAAS;CACX;CACA,OAAO,GAAG,SAAS,OAAO;CAE1B,MAAM,UAAU,YAAY;EAC1B,IAAI,QAAQ,OAAO,KAAA;EACnB,IAAI,WAAW,YAAY;GACzB,SAAS;GACT;EACF;EACA,IAAI;EACJ,IAAI;GACF,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,EAAE;EAC1C,QAAQ;GACN,SAAS;GACT;EACF;EACA,WAAW;EACX,OAAO,KAAK,WAER,IAAI,SAAe,YAAY;GAC7B,MAAM,QAAQ,UAAyB;IACrC,IAAI,OAAO,SAAS;IACpB,WAAW;IACX,QAAQ;GACV;GACA,IAAI;IACF,OAAO,MAAM,MAAM,IAAI;GACzB,QAAQ;IACN,qBAAK,IAAI,MAAM,cAAc,CAAC;GAChC;EACF,CAAC,CACL;EACA,OAAO;CACT;CAEA,MAAM,QAAQ,YAAY;EACxB,MAAM;EACN,IAAI,QAAQ;GACV,SAAS;GACT,MAAM,IAAI,MAAM,yBAAyB;EAC3C;CACF;CACA,MAAM,QAAQ,YAAY;EACxB,SAAS;EACT,IAAI;GACF,MAAM,MAAM,MAAM;EACpB,UAAU;GACR,OAAO,IAAI,SAAS,OAAO;EAC7B;CACF;CACA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raypx-kit/logger",
3
- "version": "0.0.0-alpha.0",
3
+ "version": "0.0.0-alpha.2",
4
4
  "description": "Evlog structured logging, wide events, drain pipelines and request-scoped context.",
5
5
  "homepage": "https://github.com/raypx/raypx-kit/tree/canary/packages/logger",
6
6
  "license": "Apache-2.0",
@@ -33,6 +33,14 @@
33
33
  "./pipeline": {
34
34
  "types": "./dist/pipeline.d.mts",
35
35
  "import": "./dist/pipeline.mjs"
36
+ },
37
+ "./safe": {
38
+ "types": "./dist/safe.d.mts",
39
+ "import": "./dist/safe.mjs"
40
+ },
41
+ "./stream": {
42
+ "types": "./dist/stream.d.mts",
43
+ "import": "./dist/stream.mjs"
36
44
  }
37
45
  },
38
46
  "publishConfig": {
@@ -47,7 +55,7 @@
47
55
  "test:coverage": "bun --bun vp test --run --passWithNoTests --coverage --coverage.reporter=text --coverage.reporter=json --coverage.reporter=lcov",
48
56
  "build": "tsdown",
49
57
  "dev": "tsdown --watch",
50
- "pack:check": "bun pm pack --dry-run --ignore-scripts"
58
+ "pack:check": "bun pm pack --dry-run"
51
59
  },
52
60
  "dependencies": {
53
61
  "evlog": "^2.33.1"
@@ -56,6 +64,6 @@
56
64
  "@vitest/coverage-v8": "^5.0.5"
57
65
  },
58
66
  "engines": {
59
- "node": ">=22.14"
67
+ "node": ">=22.21"
60
68
  }
61
69
  }