@raypx-kit/logger 0.0.0-alpha.0 → 0.0.0-alpha.1
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 +6 -0
- package/README.md +43 -0
- package/dist/safe.d.mts +55 -0
- package/dist/safe.mjs +181 -0
- package/dist/safe.mjs.map +1 -0
- package/dist/stream.d.mts +24 -0
- package/dist/stream.mjs +63 -0
- package/dist/stream.mjs.map +1 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @raypx-kit/logger
|
|
2
2
|
|
|
3
|
+
## 0.0.0-alpha.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
3
9
|
## 0.0.0-alpha.0
|
|
4
10
|
|
|
5
11
|
Initial Raypx Kit prerelease.
|
package/README.md
CHANGED
|
@@ -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
|
package/dist/safe.d.mts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { DrainContext } from "evlog";
|
|
2
|
+
//#region src/safe.d.ts
|
|
3
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
4
|
+
[key: string]: JsonValue;
|
|
5
|
+
};
|
|
6
|
+
/** A drained event after the policy: the fixed envelope plus allowed, cleaned fields. */
|
|
7
|
+
export type SafeEvent = Readonly<Record<string, JsonValue>> & {
|
|
8
|
+
readonly timestamp: string;
|
|
9
|
+
readonly level: string;
|
|
10
|
+
readonly service: string;
|
|
11
|
+
readonly environment: string;
|
|
12
|
+
};
|
|
13
|
+
export type SafeDrain = (context: {
|
|
14
|
+
event: SafeEvent;
|
|
15
|
+
}) => void | Promise<void>;
|
|
16
|
+
export interface EventPolicyOptions {
|
|
17
|
+
/** Top-level fields that may leave the process. Everything else is dropped. */
|
|
18
|
+
fields: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Allowed fields that keep only the listed string properties, for framework
|
|
21
|
+
* objects such as `{ rpc: ["system", "method"] }`.
|
|
22
|
+
*/
|
|
23
|
+
shapes?: Readonly<Record<string, readonly string[]>>;
|
|
24
|
+
/** Upper bound for one serialized event in UTF-8 bytes (default 16 KiB). */
|
|
25
|
+
maxEventBytes?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Field names whose values are always replaced, at any depth. Names are compared
|
|
28
|
+
* after removing non-alphanumerics and lowercasing. Defaults to
|
|
29
|
+
* {@link DEFAULT_SENSITIVE_KEYS}.
|
|
30
|
+
*/
|
|
31
|
+
sensitiveKeys?: RegExp;
|
|
32
|
+
}
|
|
33
|
+
/** Credentials, personal data, request material, process output and free text. */
|
|
34
|
+
export declare const DEFAULT_SENSITIVE_KEYS: RegExp;
|
|
35
|
+
/**
|
|
36
|
+
* Reduces any error, including evlog's serialized error field, to a standard name and
|
|
37
|
+
* an optional machine code. Messages, causes and stacks are never kept.
|
|
38
|
+
*/
|
|
39
|
+
export declare function safeError(error: unknown): {
|
|
40
|
+
name: string;
|
|
41
|
+
code?: string | number;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Returns a function that projects a raw event to its safe form: the envelope, then
|
|
45
|
+
* allowed fields in order until the byte limit. Hostile values cannot throw out of it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createEventPolicy({ fields, shapes, maxEventBytes, sensitiveKeys }: EventPolicyOptions): (event: unknown) => SafeEvent;
|
|
48
|
+
/**
|
|
49
|
+
* Wraps a drain so it only ever receives policy-projected events. Request metadata
|
|
50
|
+
* and headers from the drain context are never forwarded. A failing projection
|
|
51
|
+
* drops the event instead of interrupting the caller.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withEventPolicy(drain: SafeDrain, options: EventPolicyOptions): (context: DrainContext) => void | Promise<void>;
|
|
54
|
+
//#endregion
|
|
55
|
+
//# sourceMappingURL=safe.d.mts.map
|
package/dist/safe.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
//#region 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
|
+
//#endregion
|
|
179
|
+
export { DEFAULT_SENSITIVE_KEYS, createEventPolicy, safeError, withEventPolicy };
|
|
180
|
+
|
|
181
|
+
//# 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":";AAsCA,MAAM,WAAW;AACjB,MAAM,WAAW;CAAC;CAAa;CAAS;CAAW;AAAa;AAChE,MAAM,yBAAS,IAAI,IAAI;CAAC;CAAS;CAAS;CAAQ;CAAQ;CAAS;AAAO,CAAC;AAC3E,MAAM,iCAAiB,IAAI,IAAI;CAAC;CAAa;CAAe;CAAa;AAAQ,CAAC;AAClF,MAAM,8BAAc,IAAI,IAAI;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;AAGD,MAAa,yBACX;;AAGF,SAAS,QAAQ,OAAgB,KAAsB;CACrD,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO,KAAA;CACxD,IAAI;EACF,OAAO,OAAO,yBAAyB,OAAO,GAAG,CAAC,EAAE;CACtD,QAAQ;EACN;CACF;AACF;AAEA,SAAS,YAAY,OAAuB;CAC1C,OACE,MACG,MAAM,GAAG,GAAG,CAAC,CACb,QAAQ,uBAAuB,QAAQ,CAAC,CACxC,QAAQ,yBAAyB,QAAQ,CAAC,CAC1C,QAAQ,0DAA0D,QAAQ,CAAC,CAC3E,QAAQ,2CAA2C,QAAQ,CAAC,CAC5D,QAAQ,6DAA6D,QAAQ,CAAC,CAE9E,QAAQ,0BAA0B,GAAG,CAAC,CACtC,MAAM,GAAG,GAAG;AAEnB;;;;;AAMA,SAAgB,UAAU,OAA0D;CAClF,IAAI;EACF,IAAI,OAAO,QAAQ,OAAO,MAAM;EAChC,IAAI,SAAS,KAAA,KAAa,iBAAiB,OAAO;GAChD,IAAI,iBAAiB,WAAW,OAAO;QAClC,IAAI,iBAAiB,YAAY,OAAO;QACxC,IAAI,iBAAiB,aAAa,OAAO;QACzC,IAAI,iBAAiB,gBAAgB,OAAO;EACnD;EACA,MAAM,SAAmD,EACvD,MAAM,OAAO,SAAS,YAAY,YAAY,IAAI,IAAI,IAAI,OAAO,QACnE;EACA,MAAM,OAAO,QAAQ,OAAO,MAAM;EAClC,IAAI,OAAO,SAAS,YAAY,yBAAyB,KAAK,IAAI,GAAG,OAAO,OAAO;OAC9E,IAAI,OAAO,SAAS,YAAY,OAAO,cAAc,IAAI,GAAG,OAAO,OAAO;EAC/E,OAAO;CACT,QAAQ;EACN,OAAO,EAAE,MAAM,QAAQ;CACzB;AACF;;;;;AAMA,SAAgB,kBAAkB,EAChC,QACA,SAAS,CAAC,GACV,gBAAgB,OAChB,gBAAgB,0BACoC;CACpD,MAAM,aAAa,QACjB,cAAc,KAAK,IAAI,QAAQ,eAAe,EAAE,CAAC,CAAC,YAAY,CAAC;CACjE,MAAM,UAAU,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,QAClC,QACC,IAAI,UAAU,MACd,CAAC,eAAe,IAAI,GAAG,KACvB,CAAE,SAA+B,SAAS,GAAG,CACjD;CACA,MAAM,UAAU,IAAI,YAAY;CAEhC,SAAS,MACP,OACA,OACA,MACA,QACuB;EACvB,IAAI,OAAO,eAAe,GAAG,OAAO;EACpC,IAAI;GACF,IAAI,UAAU,QAAQ,OAAO,UAAU,WAAW,OAAO;GACzD,IAAI,OAAO,UAAU,UAAU,OAAO,YAAY,KAAK;GACvD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,SAAS,KAAK,IAAI,QAAQ,KAAA;GACvE,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;GACtC,IAAI,iBAAiB,OAAO,OAAO,UAAU,KAAK;GAClD,IAAI,SAAS,GAAG,OAAO;GACvB,IAAI,KAAK,IAAI,KAAK,GAAG,OAAO;GAC5B,KAAK,IAAI,KAAK;GACd,IAAI;IACF,IAAI,MAAM,QAAQ,KAAK,GAAG;KACxB,MAAM,SAAsB,CAAC;KAC7B,MAAM,SAAS,QAAQ,OAAO,QAAQ;KACtC,MAAM,QAAQ,KAAK,IAAI,OAAO,WAAW,WAAW,SAAS,GAAG,EAAE;KAClE,KAAK,IAAI,QAAQ,GAAG,QAAQ,SAAS,OAAO,YAAY,GAAG,SAAS;MAClE,MAAM,OAAO,MAAM,QAAQ,OAAO,OAAO,KAAK,CAAC,GAAG,QAAQ,GAAG,MAAM,MAAM;MACzE,IAAI,SAAS,KAAA,GAAW,OAAO,KAAK,IAAI;KAC1C;KACA,OAAO;IACT;IACA,MAAM,SAAoC,CAAC;IAC3C,KAAK,MAAM,OAAO,OAAO,KAAK,KAAK,CAAC,CAAC,MAAM,GAAG,EAAE,GAAG;KACjD,IAAI,OAAO,aAAa,GAAG;KAC3B,IAAI,IAAI,SAAS,MAAM,eAAe,IAAI,GAAG,GAAG;KAChD,IAAI,UAAU,GAAG,GAAG;MAClB,OAAO,OAAO;MACd;KACF;KACA,MAAM,QAAQ,MAAM,QAAQ,OAAO,GAAG,GAAG,QAAQ,GAAG,MAAM,MAAM;KAChE,IAAI,UAAU,KAAA,GAAW,OAAO,OAAO;IACzC;IACA,OAAO;GACT,QAAQ;IACN,OAAO;GACT,UAAU;IACR,KAAK,OAAO,KAAK;GACnB;EACF,QAAQ;GACN,OAAO;EACT;CACF;CAEA,QAAQ,UAAU;EAChB,MAAM,QAAQ,KAAa,aAAqB;GAC9C,MAAM,QAAQ,QAAQ,OAAO,GAAG;GAChC,OAAO,OAAO,UAAU,WAAW,YAAY,KAAK,CAAC,CAAC,MAAM,GAAG,EAAE,IAAI;EACvE;EACA,MAAM,QAAQ,QAAQ,OAAO,OAAO;EACpC,MAAM,SAAoC;GACxC,WAAW,KAAK,8BAAa,IAAI,KAAK,EAAA,CAAE,YAAY,CAAC;GACrD,OAAO,OAAO,UAAU,YAAY,OAAO,IAAI,KAAK,IAAI,QAAQ;GAChE,SAAS,KAAK,WAAW,SAAS;GAClC,aAAa,KAAK,eAAe,SAAS;EAC5C;EACA,IAAI,QAAQ,QAAQ,OAAO,KAAK,UAAU,MAAM,CAAC,CAAC,CAAC;EACnD,MAAM,SAAS,EAAE,WAAW,IAAI;EAChC,KAAK,MAAM,OAAO,SAAS;GACzB,IAAI,OAAO,aAAa,GAAG;GAC3B,OAAO,aAAa;GACpB,MAAM,QAAQ,QAAQ,OAAO,GAAG;GAChC,IAAI,UAAU,KAAA,GAAW;GACzB,IAAI;GACJ,IAAI,UAAU,GAAG,GAAG,YAAY;QAC3B,IAAI,QAAQ,SAAS,YAAY,UAAU,KAAK;QAChD,IAAI,OAAO,OAAO,QAAQ,GAAG,GAAG;IACnC,MAAM,SAAoC,CAAC;IAC3C,KAAK,MAAM,SAAS,OAAO,QAAQ,CAAC,GAAG;KACrC,MAAM,QAAQ,QAAQ,OAAO,KAAK;KAClC,IAAI,OAAO,UAAU,YAAY,CAAC,UAAU,KAAK,GAAG,OAAO,SAAS,YAAY,KAAK;IACvF;IACA,YAAY;GACd,OAAO,YAAY,MAAM,OAAO,mBAAG,IAAI,QAAQ,GAAG,MAAM;GACxD,IAAI,cAAc,KAAA,GAAW;GAC7B,MAAM,OAAO,QAAQ,OAAO,KAAK,UAAU,GAAG,MAAM,UAAU,CAAC,CAAC,CAAC,CAAC;GAClE,IAAI,QAAQ,OAAO,eAAe;GAClC,OAAO,OAAO;GACd,SAAS;EACX;EACA,OAAO,OAAO,OAAO,MAAM;CAC7B;AACF;;;;;;AAOA,SAAgB,gBACd,OACA,SACiD;CACjD,MAAM,UAAU,kBAAkB,OAAO;CACzC,QAAQ,YAAY;EAClB,IAAI;EACJ,IAAI;GACF,QAAQ,QAAQ,QAAQ,SAAS,OAAO,CAAC;EAC3C,QAAQ;GACN;EACF;EACA,OAAO,MAAM,EAAE,MAAM,CAAC;CACxB;AACF"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
//#region src/stream.d.ts
|
|
3
|
+
export interface StreamDrainOptions {
|
|
4
|
+
/** Defaults to `process.stderr`. A caller-owned stream is flushed but never ended. */
|
|
5
|
+
stream?: Writable;
|
|
6
|
+
/** Events waiting for the stream before new ones are dropped (default 1024). */
|
|
7
|
+
maxPending?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface StreamDrain {
|
|
10
|
+
(context: {
|
|
11
|
+
event: unknown;
|
|
12
|
+
}): Promise<void> | undefined;
|
|
13
|
+
/** Waits for written events; rejects once if any write or serialization failed. */
|
|
14
|
+
flush(): Promise<void>;
|
|
15
|
+
/** Stops accepting events, flushes and detaches from the stream. */
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Writes one JSON line per event, in order. Failures are recorded for flush/close
|
|
20
|
+
* instead of being thrown into the code that emitted the event.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createStreamDrain({ stream, maxPending }?: StreamDrainOptions): StreamDrain;
|
|
23
|
+
//#endregion
|
|
24
|
+
//# sourceMappingURL=stream.d.mts.map
|
package/dist/stream.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region 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
|
+
//#endregion
|
|
61
|
+
export { createStreamDrain };
|
|
62
|
+
|
|
63
|
+
//# 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.
|
|
3
|
+
"version": "0.0.0-alpha.1",
|
|
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": {
|