@kindgi/log 0.0.0-bootstrap.0 → 0.1.4-rc.5

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/package.json CHANGED
@@ -1,7 +1,48 @@
1
1
  {
2
2
  "name": "@kindgi/log",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder. The real @kindgi/log is published from github.com/kindgi/kindgi-sdk.",
3
+ "version": "0.1.4-rc.5",
4
+ "description": "Kindgi™ structured logging: levels per subsystem (with dotted inheritance), JSON and pretty formats, redaction of secret keys and token shapes, and W3C trace-context helpers. The runtime and @kindgi/api write the same records. Zero runtime dependencies.",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/kindgi/kindgi-sdk.git" }
7
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kindgi/kindgi-sdk.git",
9
+ "directory": "packages/log"
10
+ },
11
+ "homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/log#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kindgi/kindgi-sdk/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22.12.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "provenance": true
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.10.5",
39
+ "typescript": "^5.7.3",
40
+ "vitest": "^2.1.8"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "vitest run",
46
+ "clean": "rm -rf dist *.tsbuildinfo"
47
+ }
48
+ }
package/src/env.ts ADDED
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { LOG_LEVELS, type LogLevel, parseLogLevel, parseLogLevels } from './levels.js';
5
+ import { createLogger } from './logger.js';
6
+ import type { LogFormat, Logger } from './types.js';
7
+
8
+ /**
9
+ * `KINDGI_LOG_FORMAT`'s value as a format: `json` or `pretty` as given;
10
+ * `auto` (the default) is pretty on a terminal or in development mode
11
+ * (`dev`: `KINDGI_DEV`, which every `kindgi dev` sets, so an older CLI that
12
+ * passes no format still shows pretty lines), and JSON otherwise. So the
13
+ * CLI and the runtime agree. `undefined` for any other
14
+ * value.
15
+ */
16
+ export function resolveLogFormat(
17
+ raw: string | undefined,
18
+ context: { readonly isTTY: boolean; readonly dev?: boolean },
19
+ ): LogFormat | undefined {
20
+ const value = (raw ?? '').trim().toLowerCase();
21
+ if (value === '' || value === 'auto') {
22
+ return context.isTTY || context.dev === true ? 'pretty' : 'json';
23
+ }
24
+ if (value === 'json' || value === 'pretty') return value;
25
+ return undefined;
26
+ }
27
+
28
+ /** `KINDGI_LOG_LEVEL`'s level (`info` when unset), or the error that names a bad one. */
29
+ function levelFromEnv(
30
+ raw: string | undefined,
31
+ ): LogLevel | { readonly kind: 'err'; readonly message: string } {
32
+ if (raw === undefined || raw.trim() === '') return 'info';
33
+ const parsed = parseLogLevel(raw);
34
+ if (parsed !== undefined) return parsed;
35
+ return {
36
+ kind: 'err',
37
+ message: `KINDGI_LOG_LEVEL must be one of ${LOG_LEVELS.join(', ')}, got "${raw}".`,
38
+ };
39
+ }
40
+
41
+ /** `KINDGI_LOG_LEVELS` entries no subsystem logs under (nor its dotted root). */
42
+ function unknownSubsystems(
43
+ levels: Readonly<Record<string, LogLevel>>,
44
+ subsystems: readonly string[],
45
+ ): string[] {
46
+ const known = new Set(subsystems);
47
+ return Object.keys(levels)
48
+ .filter((name) => !known.has(name) && !known.has(name.split('.')[0] ?? name))
49
+ .map(
50
+ (name) =>
51
+ `KINDGI_LOG_LEVELS names "${name}", which no subsystem logs under; it has no effect.`,
52
+ );
53
+ }
54
+
55
+ export type LoggerFromEnv =
56
+ | {
57
+ readonly kind: 'ok';
58
+ readonly logger: Logger;
59
+ /** Settings that are wrong but harmless: log them at boot (an unknown subsystem, say). */
60
+ readonly problems: readonly string[];
61
+ }
62
+ | {
63
+ /** A setting that can't be honoured: refuse to start, naming it (exit code 2). */
64
+ readonly kind: 'err';
65
+ readonly message: string;
66
+ };
67
+
68
+ /**
69
+ * The root logger from `KINDGI_LOG_LEVEL`, `KINDGI_LOG_LEVELS` and
70
+ * `KINDGI_LOG_FORMAT`. An unknown level or format, or a malformed
71
+ * `KINDGI_LOG_LEVELS`, is an error; a subsystem not in `subsystems` (when
72
+ * given) is a problem to report, not a refusal, so a typo doesn't stop a
73
+ * server. Default sink: stdout.
74
+ */
75
+ export function loggerFromEnv(input: {
76
+ readonly env: Readonly<Record<string, string | undefined>>;
77
+ readonly write?: (line: string) => void;
78
+ readonly isTTY?: boolean;
79
+ /** The subsystems the caller logs under, to flag an unknown one. */
80
+ readonly subsystems?: readonly string[];
81
+ /** Extra field keys to redact. */
82
+ readonly redact?: readonly string[];
83
+ }): LoggerFromEnv {
84
+ const { env } = input;
85
+ const level = levelFromEnv(env.KINDGI_LOG_LEVEL);
86
+ if (typeof level === 'object') return level;
87
+ const levels = parseLogLevels(env.KINDGI_LOG_LEVELS ?? '');
88
+ if (levels.kind === 'err') {
89
+ return { kind: 'err', message: `KINDGI_LOG_LEVELS: ${levels.message}.` };
90
+ }
91
+ const isTTY = input.isTTY ?? false;
92
+ const dev = ['true', '1'].includes((env.KINDGI_DEV ?? '').trim().toLowerCase());
93
+ const format = resolveLogFormat(env.KINDGI_LOG_FORMAT, { isTTY, dev });
94
+ if (format === undefined) {
95
+ return {
96
+ kind: 'err',
97
+ message: `KINDGI_LOG_FORMAT must be auto, json or pretty, got "${env.KINDGI_LOG_FORMAT}".`,
98
+ };
99
+ }
100
+ const problems =
101
+ input.subsystems === undefined ? [] : unknownSubsystems(levels.levels, input.subsystems);
102
+ const write =
103
+ input.write ??
104
+ ((line: string) => {
105
+ if (typeof process !== 'undefined') process.stdout.write(`${line}\n`);
106
+ });
107
+ const logger = createLogger({
108
+ level,
109
+ levels: levels.levels,
110
+ format,
111
+ color: format === 'pretty' && isTTY && (env.NO_COLOR ?? '') === '',
112
+ write,
113
+ ...(input.redact !== undefined && { redact: input.redact }),
114
+ });
115
+ return { kind: 'ok', logger, problems };
116
+ }
package/src/format.ts ADDED
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { LogLevel } from './levels.js';
5
+ import type { LogRecord, SerializedError } from './types.js';
6
+
7
+ /** The JSON format: one record per line, the schema `LogRecord` describes. */
8
+ export function formatJson(record: LogRecord): string {
9
+ return JSON.stringify(record, (_key, value: unknown) =>
10
+ typeof value === 'bigint' ? value.toString() : value,
11
+ );
12
+ }
13
+
14
+ const LEVEL_LABEL: Readonly<Record<LogLevel, string>> = {
15
+ error: 'ERROR',
16
+ warn: 'WARN ',
17
+ info: 'INFO ',
18
+ debug: 'DEBUG',
19
+ trace: 'TRACE',
20
+ };
21
+
22
+ const COLOR: Readonly<Record<LogLevel, string>> = {
23
+ error: '\u001b[31m',
24
+ warn: '\u001b[33m',
25
+ info: '\u001b[36m',
26
+ debug: '\u001b[90m',
27
+ trace: '\u001b[90m',
28
+ };
29
+ const RESET = '\u001b[0m';
30
+ const DIM = '\u001b[2m';
31
+
32
+ const FIXED: ReadonlySet<string> = new Set([
33
+ 'time',
34
+ 'level',
35
+ 'severity',
36
+ 'subsystem',
37
+ 'message',
38
+ 'err',
39
+ ]);
40
+
41
+ function pretty(value: unknown): string {
42
+ if (typeof value === 'string')
43
+ return /[\s"=]/.test(value) || value === '' ? JSON.stringify(value) : value;
44
+ if (typeof value === 'bigint') return value.toString();
45
+ if (value === undefined) return 'undefined';
46
+ return JSON.stringify(value);
47
+ }
48
+
49
+ function errorLines(err: SerializedError): string[] {
50
+ const head = `${err.name}: ${err.message}${err.code !== undefined ? ` (${err.code})` : ''}`;
51
+ const lines = [head];
52
+ if (err.stack !== undefined) {
53
+ const frames = err.stack.split('\n').filter((l) => /^\s*at /.test(l));
54
+ lines.push(...frames.map((f) => ` ${f.trim()}`));
55
+ }
56
+ if (err.cause !== undefined) {
57
+ lines.push(
58
+ typeof err.cause === 'string'
59
+ ? ` caused by: ${err.cause}`
60
+ : ` caused by: ${errorLines(err.cause).join('\n ')}`,
61
+ );
62
+ }
63
+ return lines;
64
+ }
65
+
66
+ /**
67
+ * The pretty format, for a person at a terminal:
68
+ * `HH:MM:SS.mmm LEVEL [subsystem] message key=value …`, an error's
69
+ * frames on the lines after. Colours only when `color` is set (a
70
+ * terminal, and `NO_COLOR` unset). `omit`: fields the message already
71
+ * states (`LogOptions.inMessage`), left out of the line.
72
+ */
73
+ export function formatPretty(
74
+ record: LogRecord,
75
+ options: { readonly color?: boolean; readonly omit?: readonly string[] } = {},
76
+ ): string {
77
+ const color = options.color === true;
78
+ const time = record.time.slice(11, 23);
79
+ const label = LEVEL_LABEL[record.level];
80
+ const omit = options.omit ?? [];
81
+ const fields = Object.entries(record)
82
+ .filter(([key, value]) => !FIXED.has(key) && value !== undefined && !omit.includes(key))
83
+ .map(([key, value]) => `${key}=${pretty(value)}`);
84
+ const head = [
85
+ color ? `${DIM}${time}${RESET}` : time,
86
+ color ? `${COLOR[record.level]}${label}${RESET}` : label,
87
+ `[${record.subsystem}]`,
88
+ record.message,
89
+ ...fields,
90
+ ].join(' ');
91
+ if (record.err === undefined) return head;
92
+ const [first, ...rest] = errorLines(record.err);
93
+ return [`${head} err=${pretty(first)}`, ...rest].join('\n');
94
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `@kindgi/log` — Kindgi's structured logger: levels (per subsystem, with
6
+ * dotted inheritance), JSON and pretty formats, redaction, and the W3C
7
+ * trace-context helpers. Zero dependencies. The runtime and `@kindgi/api`
8
+ * (`createApp({ logger })`) write the same records.
9
+ */
10
+
11
+ export type {
12
+ LogFields,
13
+ LogFormat,
14
+ LogOptions,
15
+ LogRecord,
16
+ Logger,
17
+ SerializedError,
18
+ } from './types.js';
19
+ export {
20
+ LOG_LEVELS,
21
+ type LogLevel,
22
+ type ParsedLogLevels,
23
+ SEVERITY,
24
+ levelEnabled,
25
+ levelFor,
26
+ parseLogLevel,
27
+ parseLogLevels,
28
+ } from './levels.js';
29
+ export { type CreateLoggerOptions, createLogger, noopLogger } from './logger.js';
30
+ export { formatJson, formatPretty } from './format.js';
31
+ export { REDACTED, isSecretKey, redactValue, scrubText } from './redact.js';
32
+ export {
33
+ type TraceContext,
34
+ childSpan,
35
+ formatTraceparent,
36
+ newTraceContext,
37
+ parseTraceparent,
38
+ traceFromHeader,
39
+ } from './trace.js';
40
+ export { type LoggerFromEnv, loggerFromEnv, resolveLogFormat } from './env.js';
package/src/levels.ts ADDED
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Log levels, most severe first. A level says how much attention a line
6
+ * needs: `error` (an operator should look), `warn` (unexpected but
7
+ * handled), `info` (what happened, at the operator's grain), `debug` (the
8
+ * decisions behind it), `trace` (internals, briefly).
9
+ */
10
+ export const LOG_LEVELS = ['error', 'warn', 'info', 'debug', 'trace'] as const;
11
+
12
+ export type LogLevel = (typeof LOG_LEVELS)[number];
13
+
14
+ const RANK: Readonly<Record<LogLevel, number>> = {
15
+ error: 50,
16
+ warn: 40,
17
+ info: 30,
18
+ debug: 20,
19
+ trace: 10,
20
+ };
21
+
22
+ /** Cloud Logging's severity names: Cloud Run and GKE show levels with no agent configuration. */
23
+ export const SEVERITY: Readonly<Record<LogLevel, string>> = {
24
+ error: 'ERROR',
25
+ warn: 'WARNING',
26
+ info: 'INFO',
27
+ debug: 'DEBUG',
28
+ trace: 'DEBUG',
29
+ };
30
+
31
+ /** Whether a record at `level` is written by a logger at `threshold`. */
32
+ export function levelEnabled(level: LogLevel, threshold: LogLevel): boolean {
33
+ return RANK[level] >= RANK[threshold];
34
+ }
35
+
36
+ /** A level's name, case and spaces aside; `undefined` when it isn't one. */
37
+ export function parseLogLevel(raw: string): LogLevel | undefined {
38
+ const value = raw.trim().toLowerCase();
39
+ return (LOG_LEVELS as readonly string[]).includes(value) ? (value as LogLevel) : undefined;
40
+ }
41
+
42
+ export type ParsedLogLevels =
43
+ | { readonly kind: 'ok'; readonly levels: Readonly<Record<string, LogLevel>> }
44
+ | { readonly kind: 'err'; readonly message: string };
45
+
46
+ /**
47
+ * Per-subsystem levels, `KINDGI_LOG_LEVELS`'s form: a comma list of
48
+ * `subsystem=level`, such as `kernel=debug,http=warn`. Empty is none. An
49
+ * entry that isn't `name=level`, or names no known level, is an error that
50
+ * quotes it. Whether a subsystem exists is the caller's question.
51
+ */
52
+ export function parseLogLevels(raw: string): ParsedLogLevels {
53
+ const levels: Record<string, LogLevel> = {};
54
+ for (const entry of raw.split(',')) {
55
+ const trimmed = entry.trim();
56
+ if (trimmed === '') continue;
57
+ const eq = trimmed.indexOf('=');
58
+ const subsystem = eq === -1 ? '' : trimmed.slice(0, eq).trim();
59
+ const level = eq === -1 ? undefined : parseLogLevel(trimmed.slice(eq + 1));
60
+ if (subsystem === '' || level === undefined) {
61
+ return {
62
+ kind: 'err',
63
+ message: `"${trimmed}" isn't subsystem=level (levels: ${LOG_LEVELS.join(', ')})`,
64
+ };
65
+ }
66
+ levels[subsystem] = level;
67
+ }
68
+ return { kind: 'ok', levels };
69
+ }
70
+
71
+ /**
72
+ * The level for `subsystem`: its own entry, else its nearest dotted
73
+ * parent's (`kernel=debug` covers `kernel.sweeper`), else `fallback`.
74
+ */
75
+ export function levelFor(
76
+ subsystem: string,
77
+ levels: Readonly<Record<string, LogLevel>>,
78
+ fallback: LogLevel,
79
+ ): LogLevel {
80
+ let name = subsystem;
81
+ for (;;) {
82
+ const own = levels[name];
83
+ if (own !== undefined) return own;
84
+ const dot = name.lastIndexOf('.');
85
+ if (dot === -1) return fallback;
86
+ name = name.slice(0, dot);
87
+ }
88
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,188 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { formatJson, formatPretty } from './format.js';
5
+ import { type LogLevel, SEVERITY, levelEnabled, levelFor } from './levels.js';
6
+ import { redactValue, scrubText } from './redact.js';
7
+ import type {
8
+ LogFields,
9
+ LogFormat,
10
+ LogOptions,
11
+ LogRecord,
12
+ Logger,
13
+ SerializedError,
14
+ } from './types.js';
15
+
16
+ export interface CreateLoggerOptions {
17
+ /** Records below this level aren't written (`KINDGI_LOG_LEVEL`). Default `info`. */
18
+ readonly level?: LogLevel;
19
+ /** Per-subsystem levels; a dotted name inherits its parent's (`KINDGI_LOG_LEVELS`). */
20
+ readonly levels?: Readonly<Record<string, LogLevel>>;
21
+ /** `json` (one record per line) or `pretty` (for a terminal). Default `json`. */
22
+ readonly format?: LogFormat;
23
+ /** Colours in the pretty format (a terminal, `NO_COLOR` unset). Default off. */
24
+ readonly color?: boolean;
25
+ /** Where each line goes: one complete line, no trailing newline. */
26
+ readonly write: (line: string) => void;
27
+ /** More field keys to redact, on top of the built-in ones. */
28
+ readonly redact?: readonly string[];
29
+ /** The records' subsystem until a child names one. Default `root`. */
30
+ readonly subsystem?: string;
31
+ /** The clock (tests). */
32
+ readonly now?: () => Date;
33
+ }
34
+
35
+ /** Correlation fields, in the order a record carries them, after the fixed five. */
36
+ const CORRELATION = [
37
+ 'traceId',
38
+ 'spanId',
39
+ 'runTraceId',
40
+ 'requestId',
41
+ 'tenantId',
42
+ 'projectId',
43
+ 'orgId',
44
+ 'runId',
45
+ 'parentRunId',
46
+ 'agentId',
47
+ 'agentVersion',
48
+ 'flowId',
49
+ 'flowVersion',
50
+ 'toolId',
51
+ 'conversationId',
52
+ 'approvalId',
53
+ ] as const;
54
+
55
+ const FIXED: ReadonlySet<string> = new Set(['time', 'level', 'severity', 'subsystem', 'message']);
56
+
57
+ function serializeError(err: unknown, withStack: boolean, depth = 0): SerializedError | string {
58
+ if (!(err instanceof Error)) return scrubText(String(err));
59
+ const code = (err as { readonly code?: unknown }).code;
60
+ const cause = (err as { readonly cause?: unknown }).cause;
61
+ return {
62
+ name: err.name,
63
+ message: scrubText(err.message),
64
+ ...(typeof code === 'string' || typeof code === 'number' ? { code: String(code) } : {}),
65
+ ...(withStack && err.stack !== undefined && { stack: scrubText(err.stack) }),
66
+ ...(cause !== undefined && depth < 3 && { cause: serializeError(cause, withStack, depth + 1) }),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Copy a record's own fields into `out`, after the fixed five and the
72
+ * correlation ones; an `Error` among them is serialized. A field named
73
+ * like one of the fixed five can't replace it: it comes back to be kept
74
+ * under `fields`.
75
+ */
76
+ function copyFields(
77
+ out: Record<string, unknown>,
78
+ merged: Readonly<Record<string, unknown>>,
79
+ level: LogLevel,
80
+ ): Record<string, unknown> {
81
+ const reserved: Record<string, unknown> = {};
82
+ for (const [key, value] of Object.entries(merged)) {
83
+ if (value === undefined || key === 'subsystem' || key === 'err') continue;
84
+ if ((CORRELATION as readonly string[]).includes(key)) continue;
85
+ if (FIXED.has(key)) reserved[key] = value;
86
+ else out[key] = value instanceof Error ? serializeError(value, level === 'error') : value;
87
+ }
88
+ return reserved;
89
+ }
90
+
91
+ interface Shared {
92
+ readonly options: CreateLoggerOptions;
93
+ readonly threshold: LogLevel;
94
+ readonly levels: Readonly<Record<string, LogLevel>>;
95
+ readonly format: LogFormat;
96
+ readonly now: () => Date;
97
+ }
98
+
99
+ function build(shared: Shared, bindings: LogFields): Logger {
100
+ const subsystem =
101
+ typeof bindings.subsystem === 'string' && bindings.subsystem !== ''
102
+ ? bindings.subsystem
103
+ : (shared.options.subsystem ?? 'root');
104
+ const threshold = levelFor(subsystem, shared.levels, shared.threshold);
105
+ const extra = shared.options.redact ?? [];
106
+
107
+ function record(level: LogLevel, message: string, fields: LogFields | undefined): LogRecord {
108
+ const merged: Record<string, unknown> = { ...bindings, ...fields };
109
+ const out: Record<string, unknown> = {
110
+ time: shared.now().toISOString(),
111
+ level,
112
+ severity: SEVERITY[level],
113
+ subsystem,
114
+ message: scrubText(message),
115
+ };
116
+ for (const key of CORRELATION) {
117
+ if (merged[key] !== undefined) out[key] = redactValue(merged[key], extra);
118
+ }
119
+ const reserved = copyFields(out, merged, level);
120
+ const redacted = redactValue(out, extra) as Record<string, unknown>;
121
+ // The fixed five are the record's own, never redacted as fields.
122
+ for (const key of FIXED) redacted[key] = out[key];
123
+ if (Object.keys(reserved).length > 0) redacted.fields = redactValue(reserved, extra);
124
+ if (merged.err !== undefined) {
125
+ const withStack = level === 'error' || levelEnabled('debug', threshold);
126
+ redacted.err = serializeError(merged.err, withStack);
127
+ }
128
+ return redacted as LogRecord;
129
+ }
130
+
131
+ function write(level: LogLevel, message: string, fields?: LogFields, how?: LogOptions): void {
132
+ if (!levelEnabled(level, threshold)) return;
133
+ const rec = record(level, message, fields);
134
+ const line =
135
+ shared.format === 'pretty'
136
+ ? formatPretty(rec, {
137
+ color: shared.options.color === true,
138
+ ...(how?.inMessage !== undefined && { omit: how.inMessage }),
139
+ })
140
+ : formatJson(rec);
141
+ try {
142
+ shared.options.write(line);
143
+ } catch {
144
+ // A sink that fails must never fail the code that logged.
145
+ }
146
+ }
147
+
148
+ return {
149
+ error: (message, fields, how) => write('error', message, fields, how),
150
+ warn: (message, fields, how) => write('warn', message, fields, how),
151
+ info: (message, fields, how) => write('info', message, fields, how),
152
+ debug: (message, fields, how) => write('debug', message, fields, how),
153
+ trace: (message, fields, how) => write('trace', message, fields, how),
154
+ child: (more) => build(shared, { ...bindings, ...more }),
155
+ isLevelEnabled: (level) => levelEnabled(level, threshold),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * A logger: records at or above its level (per subsystem, `levels`) are
161
+ * redacted and written to `write` as JSON or pretty lines. Logging never
162
+ * throws, and a failing sink is ignored.
163
+ */
164
+ export function createLogger(options: CreateLoggerOptions): Logger {
165
+ return build(
166
+ {
167
+ options,
168
+ threshold: options.level ?? 'info',
169
+ levels: options.levels ?? {},
170
+ format: options.format ?? 'json',
171
+ now: options.now ?? (() => new Date()),
172
+ },
173
+ {},
174
+ );
175
+ }
176
+
177
+ /** A logger that writes nothing: the default where one isn't given (`createApp`). */
178
+ const noop = (): void => undefined;
179
+
180
+ export const noopLogger: Logger = {
181
+ error: noop,
182
+ warn: noop,
183
+ info: noop,
184
+ debug: noop,
185
+ trace: noop,
186
+ child: () => noopLogger,
187
+ isLevelEnabled: () => false,
188
+ };
package/src/redact.ts ADDED
@@ -0,0 +1,99 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Redaction, applied to every record before it's written, at every level.
6
+ *
7
+ * 1. **Keys.** A field whose key looks secret has its value replaced
8
+ * with `[redacted]`, at any depth: its key, lowercased with `-` and
9
+ * `_` removed, ends with `authorization`, `cookie`, `token`,
10
+ * `password`, `passwd`, `secret`, `secrets`, `apikey`, `privatekey`,
11
+ * `passphrase` or `credential(s)` (`accessToken`, `x-api-key`,
12
+ * `secrets`), plus any the caller adds. `tokenCount` and
13
+ * `promptTokens` are not secret, and stay.
14
+ * 2. **Values.** Every string (the message, fields, an error's message
15
+ * and stack) is scrubbed of known shapes only, so ids are never
16
+ * mangled: a Kindgi token (`kgi_bt_…`, `kgi_pt_…`) keeps its prefix
17
+ * and its last four characters; `Bearer <anything>` becomes
18
+ * `Bearer [redacted]`; a URL's password (`postgres://user:pass@`)
19
+ * becomes `user:***@`.
20
+ *
21
+ * The first rule is the code rule's net: secret values are never fields
22
+ * to begin with (pass a secret's name, not its value).
23
+ */
24
+
25
+ export const REDACTED = '[redacted]';
26
+
27
+ const SECRET_KEY_ENDINGS: readonly string[] = [
28
+ 'authorization',
29
+ 'cookie',
30
+ 'token',
31
+ 'password',
32
+ 'passwd',
33
+ 'secret',
34
+ 'secrets',
35
+ 'apikey',
36
+ 'privatekey',
37
+ 'passphrase',
38
+ 'credential',
39
+ 'credentials',
40
+ ];
41
+
42
+ const normalize = (key: string): string => key.toLowerCase().replace(/[-_]/g, '');
43
+
44
+ /** Whether a field's key looks secret (see rule 1). */
45
+ export function isSecretKey(key: string, extra: readonly string[] = []): boolean {
46
+ const k = normalize(key);
47
+ return [...SECRET_KEY_ENDINGS, ...extra.map(normalize)].some((end) => k.endsWith(end));
48
+ }
49
+
50
+ /** A Kindgi token: `kgi_` and a two-letter kind, then its body. */
51
+ const KINDGI_TOKEN = /\bkgi_([a-z]{2})_([A-Za-z0-9._~+/=-]+)/g;
52
+ const BEARER = /\b(Bearer)\s+[^\s"',;]+/gi;
53
+ const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi;
54
+
55
+ /** A string with known secret shapes masked (see rule 2). */
56
+ export function scrubText(text: string): string {
57
+ return text
58
+ .replace(KINDGI_TOKEN, (_m, kind: string, body: string) => {
59
+ const tail = body.length >= 16 ? body.slice(-4) : '';
60
+ return `kgi_${kind}_…${tail}`;
61
+ })
62
+ .replace(BEARER, '$1 [redacted]')
63
+ .replace(URL_PASSWORD, '$1:***@');
64
+ }
65
+
66
+ const MAX_DEPTH = 8;
67
+
68
+ /**
69
+ * A value with both rules applied, recursively: secret keys redacted,
70
+ * strings scrubbed. Plain objects and arrays are copied; anything deeper
71
+ * than a few levels, or a cycle (an object inside itself), becomes a
72
+ * marker. An object referenced twice side by side is copied twice.
73
+ */
74
+ export function redactValue(
75
+ value: unknown,
76
+ extraKeys: readonly string[] = [],
77
+ depth = 0,
78
+ seen: WeakSet<object> = new WeakSet(),
79
+ ): unknown {
80
+ if (typeof value === 'string') return scrubText(value);
81
+ if (value === null || typeof value !== 'object') return value;
82
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
83
+ if (seen.has(value)) return '[circular]';
84
+ if (depth >= MAX_DEPTH) return '[too deep]';
85
+ seen.add(value);
86
+ try {
87
+ if (Array.isArray(value)) return value.map((v) => redactValue(v, extraKeys, depth + 1, seen));
88
+ const out: Record<string, unknown> = {};
89
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
90
+ out[key] = isSecretKey(key, extraKeys)
91
+ ? REDACTED
92
+ : redactValue(v, extraKeys, depth + 1, seen);
93
+ }
94
+ return out;
95
+ } finally {
96
+ // `seen` holds the path from the root: a cycle is an object inside itself.
97
+ seen.delete(value);
98
+ }
99
+ }