tina4-nodejs 3.13.97 → 3.13.99
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/CLAUDE.md +60 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- package/packages/core/src/errorOverlay.test.ts +0 -122
|
@@ -1,83 +1,367 @@
|
|
|
1
1
|
import {
|
|
2
2
|
appendFileSync,
|
|
3
|
+
closeSync,
|
|
3
4
|
existsSync,
|
|
4
5
|
mkdirSync,
|
|
6
|
+
openSync,
|
|
5
7
|
renameSync,
|
|
6
8
|
statSync,
|
|
7
9
|
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
10
|
} from "node:fs";
|
|
10
|
-
import { join, dirname, isAbsolute } from "node:path";
|
|
11
|
+
import { join, dirname, isAbsolute, basename } from "node:path";
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
11
14
|
import { isTruthy } from "./dotenv.js";
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Structured logger for Tina4. Conformant to the shared cross-framework
|
|
18
|
+
* contract at plan/v3/fixtures/logger_contract.json (feature 2), decided in
|
|
19
|
+
* plan/v3/features/002-structured-logger.md and ADR-0041, with the
|
|
20
|
+
* 2026-08-10 override of decisions 8 and 20 (separate console/file levels;
|
|
21
|
+
* single-file, in-process lock only).
|
|
22
|
+
*
|
|
23
|
+
* Rewritten 2026-08-13 alongside the shared conformance pass. Node's OWN
|
|
24
|
+
* adaptations, each real and each documented at the point it applies:
|
|
25
|
+
*
|
|
26
|
+
* - Request id: AsyncLocalStorage, not a manual clear-in-finally. A
|
|
27
|
+
* request's id is scoped to the ALS `run()` call around its dispatch, so
|
|
28
|
+
* it cannot leak into a sibling request and needs no "clear" step for
|
|
29
|
+
* correctness (see runWithRequestId below).
|
|
30
|
+
* - Fork (Decision 12's fork-discard requirement): child_process.fork()
|
|
31
|
+
* spawns a genuinely NEW process re-executing the module from scratch —
|
|
32
|
+
* unlike POSIX fork() in Python/PHP/Ruby, it shares no memory with the
|
|
33
|
+
* parent, so there is nothing to inherit and nothing to discard. A child
|
|
34
|
+
* process starts with `activeSnapshot === null` by construction.
|
|
35
|
+
* - The in-process lock (Decision 20): synchronous fs calls on the event
|
|
36
|
+
* loop's single thread cannot interleave (log() never awaits), so the
|
|
37
|
+
* common case needs no lock at all. Where a real lock is exercised (a
|
|
38
|
+
* worker_threads writer, or the LOG-E05 conformance case), it is backed
|
|
39
|
+
* by a real SharedArrayBuffer + Atomics — Node's actual cross-thread
|
|
40
|
+
* synchronous primitive — not a simulation.
|
|
41
|
+
*/
|
|
15
42
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Error taxonomy — three categories, matching every other Tina4 language.
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
export class LogConfigurationError extends Error {
|
|
48
|
+
setting?: string;
|
|
49
|
+
value?: unknown;
|
|
50
|
+
accepted?: string[];
|
|
51
|
+
sink?: string;
|
|
52
|
+
operation?: string;
|
|
53
|
+
constructor(
|
|
54
|
+
message: string,
|
|
55
|
+
opts: { setting?: string; value?: unknown; accepted?: string[]; sink?: string; operation?: string } = {},
|
|
56
|
+
) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "LogConfigurationError";
|
|
59
|
+
Object.assign(this, opts);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class LogArgumentError extends Error {
|
|
64
|
+
argument?: string;
|
|
65
|
+
accepted?: string[];
|
|
66
|
+
constructor(message: string, opts: { argument?: string; accepted?: string[] } = {}) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "LogArgumentError";
|
|
69
|
+
Object.assign(this, opts);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class LogWriteError extends Error {
|
|
74
|
+
sink?: string;
|
|
75
|
+
operation?: string;
|
|
76
|
+
constructor(message: string, opts: { sink?: string; operation?: string } = {}) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "LogWriteError";
|
|
79
|
+
Object.assign(this, opts);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Constants — identical names and values across all four frameworks.
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export type LogLevel = "ALL" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL" | "NONE";
|
|
88
|
+
type EventLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
|
89
|
+
|
|
90
|
+
const LEVELS: Record<LogLevel, number> = {
|
|
91
|
+
ALL: 0,
|
|
92
|
+
DEBUG: 1,
|
|
93
|
+
INFO: 2,
|
|
94
|
+
WARNING: 3,
|
|
95
|
+
ERROR: 4,
|
|
96
|
+
CRITICAL: 5,
|
|
97
|
+
NONE: 6,
|
|
23
98
|
};
|
|
99
|
+
const EVENT_LEVELS: EventLevel[] = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"];
|
|
100
|
+
|
|
101
|
+
const DEFAULT_LEVEL: LogLevel = "INFO";
|
|
102
|
+
const DEFAULT_FILE_LEVEL: LogLevel = "ALL";
|
|
103
|
+
const DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024;
|
|
104
|
+
const MIN_ROTATE_SIZE = 1024;
|
|
105
|
+
const DEFAULT_ROTATE_KEEP = 5;
|
|
106
|
+
const STDOUT_MAX_BYTES = 8192;
|
|
107
|
+
const OVERFLOW_MESSAGE = "Log event omitted: encoded size exceeds sink limit";
|
|
108
|
+
const DEFAULT_LOG_DIR = "logs";
|
|
109
|
+
const DEFAULT_LOG_FILE_NAME = "tina4.log";
|
|
110
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
111
|
+
|
|
112
|
+
// Settings that used to exist and now hard-fail configuration (Decision 19).
|
|
113
|
+
const REMOVED_SETTINGS: Record<string, string> = {
|
|
114
|
+
TINA4_LOG_MAX_SIZE: "removed; use TINA4_LOG_ROTATE_SIZE",
|
|
115
|
+
TINA4_LOG_KEEP: "removed; use TINA4_LOG_ROTATE_KEEP",
|
|
116
|
+
TINA4_LOG_APPEND: "removed; the logger always appends",
|
|
117
|
+
TINA4_DEBUG_LEVEL: "removed; use TINA4_LOG_LEVEL",
|
|
118
|
+
TINA4_LOG_CRITICAL: "removed; critical() always emits, subject only to the threshold",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const COLORS: Record<EventLevel, string> = {
|
|
122
|
+
DEBUG: "\x1b[36m",
|
|
123
|
+
INFO: "\x1b[32m",
|
|
124
|
+
WARNING: "\x1b[33m",
|
|
125
|
+
ERROR: "\x1b[31m",
|
|
126
|
+
CRITICAL: "\x1b[35m",
|
|
127
|
+
};
|
|
128
|
+
const RESET = "\x1b[0m";
|
|
129
|
+
// eslint-disable-next-line no-control-regex
|
|
130
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
131
|
+
|
|
132
|
+
function parseLevel(raw: string, setting: string): LogLevel {
|
|
133
|
+
const key = raw.trim().toUpperCase() as LogLevel;
|
|
134
|
+
if (!(key in LEVELS)) {
|
|
135
|
+
throw new LogConfigurationError(`${setting}=${JSON.stringify(raw)} is not a valid level`, {
|
|
136
|
+
setting,
|
|
137
|
+
value: raw,
|
|
138
|
+
accepted: Object.keys(LEVELS),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return key;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// Request id — AsyncLocalStorage (Decision 12, Node adaptation above).
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
const requestIdStore = new AsyncLocalStorage<{ id: string | undefined }>();
|
|
149
|
+
let requestIdFallback: string | undefined;
|
|
150
|
+
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// Native value normalization (Decision 14).
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
156
|
+
if (typeof value !== "object" || value === null) return false;
|
|
157
|
+
if (Array.isArray(value)) return false;
|
|
158
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return false;
|
|
159
|
+
const proto = Object.getPrototypeOf(value);
|
|
160
|
+
return proto === Object.prototype || proto === null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Strict UTF-8 decode; invalid bytes become a sha256-addressed marker
|
|
164
|
+
* rather than a silent U+FFFD replacement, which would hide exactly the
|
|
165
|
+
* bytes an operator needs to diagnose (LOG-F09). */
|
|
166
|
+
function decodeMaybeBinary(buf: Buffer): string {
|
|
167
|
+
try {
|
|
168
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
169
|
+
} catch {
|
|
170
|
+
const hash = createHash("sha256").update(buf).digest("hex");
|
|
171
|
+
return `<binary ${buf.length} bytes sha256=${hash}>`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
24
174
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Recursively normalize a native value into the logger's safe domain:
|
|
177
|
+
* string (a Buffer/Uint8Array is treated as string-shaped: valid UTF-8
|
|
178
|
+
* decodes, invalid becomes the binary marker above), null, boolean, finite
|
|
179
|
+
* number, array, or plain object (string keys, own enumerable properties
|
|
180
|
+
* only). Anything outside that domain becomes the literal "[Unsupported]"
|
|
181
|
+
* WITHOUT EVER invoking the value's own toString/toJSON/valueOf (LOG-F10) —
|
|
182
|
+
* the domain check is entirely structural (typeof / Array.isArray /
|
|
183
|
+
* prototype identity), so a hostile object with a throwing stringifier is
|
|
184
|
+
* never given the chance to run it. A circular array/object is caught via a
|
|
185
|
+
* real ancestor identity chain (===) and becomes "[Circular]" (LOG-F08).
|
|
186
|
+
*/
|
|
187
|
+
function normalize(value: unknown, ancestors: unknown[] = []): unknown {
|
|
188
|
+
if (value === null || value === undefined) return null;
|
|
189
|
+
if (typeof value === "boolean") return value;
|
|
190
|
+
if (typeof value === "string") return value;
|
|
191
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : "[Unsupported]";
|
|
192
|
+
if (Buffer.isBuffer(value)) return decodeMaybeBinary(value);
|
|
193
|
+
if (value instanceof Uint8Array) return decodeMaybeBinary(Buffer.from(value));
|
|
194
|
+
if (Array.isArray(value)) {
|
|
195
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
196
|
+
const nxt = [...ancestors, value];
|
|
197
|
+
return value.map((v) => normalize(v, nxt));
|
|
198
|
+
}
|
|
199
|
+
if (isPlainObject(value)) {
|
|
200
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
201
|
+
const nxt = [...ancestors, value];
|
|
202
|
+
const out: Record<string, unknown> = {};
|
|
203
|
+
for (const k of Object.keys(value)) {
|
|
204
|
+
out[k] = normalize((value as Record<string, unknown>)[k], nxt);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
return "[Unsupported]";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sortKeysRecursive(value: unknown): unknown {
|
|
212
|
+
if (Array.isArray(value)) return value.map(sortKeysRecursive);
|
|
213
|
+
if (value !== null && typeof value === "object") {
|
|
214
|
+
const src = value as Record<string, unknown>;
|
|
215
|
+
const out: Record<string, unknown> = {};
|
|
216
|
+
for (const k of Object.keys(src).sort()) out[k] = sortKeysRecursive(src[k]);
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Compact, sorted-key JSON — the ONE spelling used for a native (non-string)
|
|
223
|
+
* message AND for the context sub-object, in BOTH text and json format. */
|
|
224
|
+
function compactJson(value: unknown): string {
|
|
225
|
+
return JSON.stringify(sortKeysRecursive(value));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The message field's string spelling (LOG-F04/F05). A raw string message is
|
|
230
|
+
* used verbatim. A Buffer/Uint8Array message goes through the same
|
|
231
|
+
* decode-or-describe binary handling. Everything else is normalized then
|
|
232
|
+
* rendered as compact sorted-key JSON — even in TEXT format, so an object
|
|
233
|
+
* message is never "[object Object]".
|
|
234
|
+
*/
|
|
235
|
+
function messageToString(rawMessage: unknown): string {
|
|
236
|
+
if (typeof rawMessage === "string") return rawMessage;
|
|
237
|
+
if (Buffer.isBuffer(rawMessage)) return decodeMaybeBinary(rawMessage);
|
|
238
|
+
if (rawMessage instanceof Uint8Array) return decodeMaybeBinary(Buffer.from(rawMessage));
|
|
239
|
+
const normalized = normalize(rawMessage);
|
|
240
|
+
// A non-string/non-buffer raw value can only normalize to a STRING via one
|
|
241
|
+
// of normalize()'s own marker paths ("[Circular]", "[Unsupported]", or a
|
|
242
|
+
// binary-decode result) -- numbers/booleans/null/arrays/objects never
|
|
243
|
+
// become strings through ordinary normalization. Use that marker directly;
|
|
244
|
+
// routing it through compactJson would re-encode it as a JSON string
|
|
245
|
+
// literal (wrapping it in quotes), which is wrong for LOG-F10.
|
|
246
|
+
if (typeof normalized === "string") return normalized;
|
|
247
|
+
return compactJson(normalized);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Single combined-pass escape - backslash, CR, LF - so one log call is
|
|
252
|
+
* exactly one physical LF-terminated line (LOG-F06). A regex with a replacer
|
|
253
|
+
* function makes one pass over the ORIGINAL string and cannot re-scan its own
|
|
254
|
+
* output, unlike sequential string-replace calls (fix backslashes, THEN fix
|
|
255
|
+
* newlines), which would double-escape a backslash a newline fix just wrote.
|
|
256
|
+
*/
|
|
257
|
+
function escapeText(str: string): string {
|
|
258
|
+
return str.replace(/[\\\r\n]/g, (c) => (c === "\\" ? "\\\\" : c === "\r" ? "\\r" : "\\n"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Canonical event (Decision 15/23) — json_key_order: timestamp, level,
|
|
263
|
+
// message, request_id, function, context.
|
|
264
|
+
// ============================================================================
|
|
265
|
+
|
|
266
|
+
interface LogEvent {
|
|
27
267
|
timestamp: string;
|
|
28
|
-
level:
|
|
268
|
+
level: EventLevel;
|
|
29
269
|
message: string;
|
|
30
270
|
request_id?: string;
|
|
31
271
|
function?: string;
|
|
32
272
|
context?: unknown;
|
|
33
273
|
}
|
|
34
274
|
|
|
275
|
+
function buildEvent(
|
|
276
|
+
level: EventLevel,
|
|
277
|
+
rawMessage: unknown,
|
|
278
|
+
requestId: string | undefined,
|
|
279
|
+
callerName: string | undefined,
|
|
280
|
+
rawContext: unknown,
|
|
281
|
+
): LogEvent {
|
|
282
|
+
const event: LogEvent = {
|
|
283
|
+
timestamp: new Date().toISOString(),
|
|
284
|
+
level,
|
|
285
|
+
message: messageToString(rawMessage),
|
|
286
|
+
};
|
|
287
|
+
if (requestId) event.request_id = requestId;
|
|
288
|
+
if (callerName) event.function = callerName;
|
|
289
|
+
// Sort recursively at BUILD time (not just when rendering text) so the
|
|
290
|
+
// JSON encoder -- which stringifies the event object directly rather than
|
|
291
|
+
// routing through compactJson -- also gets sorted keys (LOG-F03).
|
|
292
|
+
if (rawContext !== undefined) event.context = sortKeysRecursive(normalize(rawContext));
|
|
293
|
+
return event;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function encodeJson(event: LogEvent): string {
|
|
297
|
+
return `${JSON.stringify(event)}\n`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function encodeText(event: LogEvent): string {
|
|
301
|
+
const paddedLevel = event.level.padEnd(8);
|
|
302
|
+
const reqPart = event.request_id ? ` [${event.request_id}]` : "";
|
|
303
|
+
const fnPart = event.function ? ` [${event.function}]` : "";
|
|
304
|
+
const ctxPart = event.context !== undefined ? ` ${compactJson(event.context)}` : "";
|
|
305
|
+
return `${event.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${escapeText(event.message)}${ctxPart}\n`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function encode(event: LogEvent, format: "text" | "json"): string {
|
|
309
|
+
return format === "json" ? encodeJson(event) : encodeText(event);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function sha256Hex(text: string): string {
|
|
313
|
+
return createHash("sha256").update(Buffer.from(text, "utf-8")).digest("hex");
|
|
314
|
+
}
|
|
315
|
+
|
|
35
316
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
317
|
+
* Replace an oversized encoded record with a small, valid, self-describing
|
|
318
|
+
* replacement carrying the ORIGINAL byte length and its sha256 (LOG-F12) — a
|
|
319
|
+
* witness an operator can use to prove nothing silently vanished, without
|
|
320
|
+
* ever storing the too-large payload itself.
|
|
38
321
|
*/
|
|
322
|
+
function overflowRecord(original: LogEvent, encoded: string, format: "text" | "json"): string {
|
|
323
|
+
const replacement: LogEvent = {
|
|
324
|
+
timestamp: original.timestamp,
|
|
325
|
+
level: original.level,
|
|
326
|
+
message: OVERFLOW_MESSAGE,
|
|
327
|
+
};
|
|
328
|
+
if (original.request_id) replacement.request_id = original.request_id;
|
|
329
|
+
if (original.function) replacement.function = original.function;
|
|
330
|
+
replacement.context = {
|
|
331
|
+
truncated: true,
|
|
332
|
+
original_bytes: Buffer.byteLength(encoded, "utf-8"),
|
|
333
|
+
sha256: sha256Hex(encoded),
|
|
334
|
+
};
|
|
335
|
+
return encode(replacement, format);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function boundedForSink(event: LogEvent, format: "text" | "json", maxBytes: number): string {
|
|
339
|
+
const encoded = encode(event, format);
|
|
340
|
+
if (Buffer.byteLength(encoded, "utf-8") <= maxBytes) return encoded;
|
|
341
|
+
return overflowRecord(event, encoded, format);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// Real caller-name capture (Decision 16) — TINA4_LOG_FUNC opt-in.
|
|
346
|
+
// ============================================================================
|
|
347
|
+
|
|
39
348
|
const OWN_FRAMES = new Set<string>([
|
|
40
|
-
"log", "Log.log",
|
|
41
|
-
"callerName", "Log.callerName",
|
|
42
|
-
"info", "debug", "warning", "
|
|
43
|
-
"Log.info", "Log.debug", "Log.warning", "Log.
|
|
349
|
+
"log", "Log.log", "emit", "Log.emit",
|
|
350
|
+
"callerName", "Log.callerName", "resolveCallerName", "Log.resolveCallerName",
|
|
351
|
+
"info", "debug", "warning", "error", "critical",
|
|
352
|
+
"Log.info", "Log.debug", "Log.warning", "Log.error", "Log.critical",
|
|
44
353
|
]);
|
|
45
|
-
|
|
46
|
-
/** V8 stack-trace markers that mean "no real function name". */
|
|
47
354
|
const ANON_NAMES = new Set<string>(["", "anonymous", "<anonymous>"]);
|
|
48
355
|
|
|
49
|
-
|
|
50
|
-
* Return the function name that called Log.{info,debug,warning,error}.
|
|
51
|
-
*
|
|
52
|
-
* Active only when `TINA4_LOG_FUNC=true` — captures `new Error().stack`,
|
|
53
|
-
* walks past Log's own frames (info / warn / error / debug / log /
|
|
54
|
-
* callerName) and returns the first user function name. Anonymous frames
|
|
55
|
-
* (`anonymous`, `<anonymous>`, bare file paths) are filtered out as noise.
|
|
56
|
-
* Returns undefined on any error — never throws. Parity feature #41 across
|
|
57
|
-
* all four Tina4 frameworks.
|
|
58
|
-
*/
|
|
59
|
-
function callerName(): string | undefined {
|
|
60
|
-
// Read the env directly (not via Env.bool) to keep the logger ↔ env cycle
|
|
61
|
-
// one-way: env helpers depend on Log, not the other way around.
|
|
62
|
-
const raw = (process.env.TINA4_LOG_FUNC ?? "").trim().toLowerCase();
|
|
63
|
-
if (raw !== "1" && raw !== "true" && raw !== "on" && raw !== "yes" && raw !== "y" && raw !== "t") {
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
356
|
+
function resolveCallerName(): string | undefined {
|
|
66
357
|
try {
|
|
67
358
|
const stack = new Error().stack;
|
|
68
359
|
if (!stack) return undefined;
|
|
69
360
|
const lines = stack.split("\n");
|
|
70
|
-
// Skip line 0 ("Error") and walk frames. Cap at 32 to defend against
|
|
71
|
-
// pathological recursion / wrapper stacks.
|
|
72
361
|
for (let i = 1; i < lines.length && i < 32; i++) {
|
|
73
|
-
const
|
|
74
|
-
// V8 frame: " at functionName (file:line:col)"
|
|
75
|
-
// or " at file:line:col" (anonymous)
|
|
76
|
-
// or " at async functionName (...)"
|
|
77
|
-
const m = line.match(/^\s+at\s+(?:async\s+)?([^\s(]+)\s*\(/);
|
|
362
|
+
const m = lines[i].match(/^\s+at\s+(?:async\s+)?([^\s(]+)\s*\(/);
|
|
78
363
|
if (!m) continue;
|
|
79
364
|
const name = m[1];
|
|
80
|
-
// Strip the leading `Object.` / class prefix some V8s emit, then check.
|
|
81
365
|
const bare = name.includes(".") ? name.split(".").pop()! : name;
|
|
82
366
|
if (OWN_FRAMES.has(name) || OWN_FRAMES.has(bare)) continue;
|
|
83
367
|
if (ANON_NAMES.has(bare)) continue;
|
|
@@ -89,616 +373,652 @@ function callerName(): string | undefined {
|
|
|
89
373
|
}
|
|
90
374
|
}
|
|
91
375
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
376
|
+
// ============================================================================
|
|
377
|
+
// A real, working lock (Decision 20) — SharedArrayBuffer + Atomics, Node's
|
|
378
|
+
// actual synchronous cross-thread blocking primitive. On the ordinary main
|
|
379
|
+
// thread there is no contention to protect against (log() never awaits, so
|
|
380
|
+
// nothing can preempt a write mid-flight) but the SAME primitive is what
|
|
381
|
+
// makes the lock genuinely acquirable/contendable from a real
|
|
382
|
+
// worker_threads.Worker when one is handed this buffer — see
|
|
383
|
+
// logger_fixture_contract.test.ts's LOG-R07 and LOG-E05 cases, which do
|
|
384
|
+
// exactly that instead of simulating contention.
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
387
|
+
const LOCK_UNLOCKED = 0;
|
|
388
|
+
const LOCK_LOCKED = 1;
|
|
389
|
+
|
|
390
|
+
class SinkLock {
|
|
391
|
+
readonly buffer: SharedArrayBuffer;
|
|
392
|
+
private readonly view: Int32Array;
|
|
393
|
+
|
|
394
|
+
constructor(buffer?: SharedArrayBuffer) {
|
|
395
|
+
this.buffer = buffer ?? new SharedArrayBuffer(4);
|
|
396
|
+
this.view = new Int32Array(this.buffer);
|
|
397
|
+
}
|
|
101
398
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
399
|
+
/** Blocks THIS thread (Atomics.wait) until acquired or the timeout elapses. */
|
|
400
|
+
tryAcquire(timeoutMs: number): boolean {
|
|
401
|
+
const deadline = Date.now() + timeoutMs;
|
|
402
|
+
for (;;) {
|
|
403
|
+
if (Atomics.compareExchange(this.view, 0, LOCK_UNLOCKED, LOCK_LOCKED) === LOCK_UNLOCKED) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
const remaining = deadline - Date.now();
|
|
407
|
+
if (remaining <= 0) return false;
|
|
408
|
+
// Atomics.wait blocks the calling thread for real (main thread or a
|
|
409
|
+
// worker) until the value changes or the timeout elapses — this is
|
|
410
|
+
// what makes the wait bounded rather than a busy spin.
|
|
411
|
+
Atomics.wait(this.view, 0, LOCK_LOCKED, Math.min(remaining, 25));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
104
414
|
|
|
105
|
-
|
|
106
|
-
|
|
415
|
+
release(): void {
|
|
416
|
+
Atomics.store(this.view, 0, LOCK_UNLOCKED);
|
|
417
|
+
Atomics.notify(this.view, 0);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
107
420
|
|
|
108
|
-
|
|
109
|
-
|
|
421
|
+
// ============================================================================
|
|
422
|
+
// File sink — predictive rotation (check BEFORE append: current + next >
|
|
423
|
+
// rotate_size; exact equality does NOT rotate), bounded lock acquisition.
|
|
424
|
+
// ============================================================================
|
|
425
|
+
|
|
426
|
+
class LogFileSink {
|
|
427
|
+
readonly path: string;
|
|
428
|
+
private rotateSize: number;
|
|
429
|
+
private rotateKeep: number;
|
|
430
|
+
private lock: SinkLock;
|
|
431
|
+
|
|
432
|
+
constructor(path: string, rotateSize: number, rotateKeep: number, lockBuffer?: SharedArrayBuffer) {
|
|
433
|
+
this.path = path;
|
|
434
|
+
this.rotateSize = rotateSize;
|
|
435
|
+
this.rotateKeep = rotateKeep;
|
|
436
|
+
this.lock = new SinkLock(lockBuffer);
|
|
437
|
+
}
|
|
110
438
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
439
|
+
/** Exposed only so a conformance test can hand the SAME lock to a real
|
|
440
|
+
* worker_threads.Worker and prove real contention/timeout (LOG-E05). */
|
|
441
|
+
get lockBuffer(): SharedArrayBuffer {
|
|
442
|
+
return this.lock.buffer;
|
|
443
|
+
}
|
|
113
444
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
445
|
+
/** Create the directory and prove the file is writable (LOG-E01: configure()
|
|
446
|
+
* itself proves the sink opens, before any snapshot is committed). */
|
|
447
|
+
open(): void {
|
|
448
|
+
try {
|
|
449
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
450
|
+
const fd = openSync(this.path, "a");
|
|
451
|
+
closeSync(fd);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
throw new LogConfigurationError(`cannot open log sink ${this.path}: ${(err as Error).message}`, {
|
|
454
|
+
sink: this.path,
|
|
455
|
+
operation: "open",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
116
459
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
460
|
+
private rotateIfNeeded(nextRecordBytes: number): void {
|
|
461
|
+
let currentSize = 0;
|
|
462
|
+
try {
|
|
463
|
+
currentSize = existsSync(this.path) ? statSync(this.path).size : 0;
|
|
464
|
+
} catch {
|
|
465
|
+
currentSize = 0;
|
|
466
|
+
}
|
|
467
|
+
if (currentSize === 0) return;
|
|
468
|
+
if (currentSize + nextRecordBytes <= this.rotateSize) return;
|
|
121
469
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
*/
|
|
131
|
-
// The logger must never be surprised by what it is handed, and must never be
|
|
132
|
-
// the reason a request dies. Same numbers in all four frameworks (feature 2).
|
|
133
|
-
const STDOUT_MAX_CHARS = 2000;
|
|
134
|
-
// eslint-disable-next-line no-control-regex
|
|
135
|
-
const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
470
|
+
if (this.rotateKeep <= 0) {
|
|
471
|
+
try {
|
|
472
|
+
unlinkSync(this.path);
|
|
473
|
+
} catch {
|
|
474
|
+
/* nothing to discard */
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
136
478
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} else if (typeof message === "object") {
|
|
479
|
+
const oldest = `${this.path}.${this.rotateKeep}`;
|
|
480
|
+
if (existsSync(oldest)) {
|
|
481
|
+
try {
|
|
482
|
+
unlinkSync(oldest);
|
|
483
|
+
} catch {
|
|
484
|
+
/* best effort */
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
for (let n = this.rotateKeep - 1; n >= 1; n--) {
|
|
488
|
+
const src = `${this.path}.${n}`;
|
|
489
|
+
const dst = `${this.path}.${n + 1}`;
|
|
490
|
+
if (existsSync(src)) {
|
|
491
|
+
try {
|
|
492
|
+
renameSync(src, dst);
|
|
493
|
+
} catch {
|
|
494
|
+
/* best effort */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
157
498
|
try {
|
|
158
|
-
|
|
499
|
+
renameSync(this.path, `${this.path}.1`);
|
|
159
500
|
} catch {
|
|
160
|
-
|
|
501
|
+
/* best effort */
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Append one complete encoded record, rotating first if it would cross
|
|
506
|
+
* the threshold. Raises LogWriteError (timeout/write) to the caller, which
|
|
507
|
+
* applies the sink failure policy (strict/non-strict). */
|
|
508
|
+
write(encodedLine: string): void {
|
|
509
|
+
const acquired = this.lock.tryAcquire(LOCK_TIMEOUT_MS);
|
|
510
|
+
if (!acquired) {
|
|
511
|
+
throw new LogWriteError(`timed out acquiring the log sink lock for ${this.path}`, {
|
|
512
|
+
sink: this.path,
|
|
513
|
+
operation: "lock",
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const payload = Buffer.from(encodedLine.replace(ANSI_RE, ""), "utf-8");
|
|
518
|
+
this.rotateIfNeeded(payload.byteLength);
|
|
519
|
+
appendFileSync(this.path, payload);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
if (err instanceof LogWriteError) throw err;
|
|
522
|
+
throw new LogWriteError(`cannot write log sink ${this.path}: ${(err as Error).message}`, {
|
|
523
|
+
sink: this.path,
|
|
524
|
+
operation: "write",
|
|
525
|
+
});
|
|
526
|
+
} finally {
|
|
527
|
+
this.lock.release();
|
|
161
528
|
}
|
|
162
|
-
} else {
|
|
163
|
-
text = String(message);
|
|
164
529
|
}
|
|
165
|
-
return text.replace(CONTROL_CHARS, "");
|
|
166
530
|
}
|
|
167
531
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// Configuration resolution
|
|
534
|
+
// ============================================================================
|
|
535
|
+
|
|
536
|
+
interface ConfigureOptions {
|
|
537
|
+
logDir?: string;
|
|
538
|
+
logFile?: string;
|
|
539
|
+
level?: string;
|
|
540
|
+
fileLevel?: string;
|
|
541
|
+
format?: string;
|
|
542
|
+
output?: string;
|
|
543
|
+
rotateSize?: number;
|
|
544
|
+
rotateKeep?: number;
|
|
545
|
+
strict?: boolean;
|
|
546
|
+
caller?: boolean;
|
|
172
547
|
}
|
|
173
548
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
549
|
+
interface Snapshot {
|
|
550
|
+
level: LogLevel;
|
|
551
|
+
fileLevel: LogLevel;
|
|
552
|
+
format: "text" | "json";
|
|
553
|
+
stdoutEnabled: boolean;
|
|
554
|
+
fileEnabled: boolean;
|
|
555
|
+
outputSelector: "stdout" | "file" | "both";
|
|
556
|
+
logDir: string;
|
|
557
|
+
logFile: string | null;
|
|
558
|
+
layout: "directory" | "single";
|
|
559
|
+
rotateSize: number;
|
|
560
|
+
rotateKeep: number;
|
|
561
|
+
strict: boolean;
|
|
562
|
+
callerCapture: boolean;
|
|
563
|
+
mainSink: LogFileSink | null;
|
|
564
|
+
errorSink: LogFileSink | null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Is this target a FILE PATH or a DIRECTORY? Identical rule in all four
|
|
568
|
+
* frameworks: an existing directory is always a directory; otherwise a
|
|
569
|
+
* basename with an extension is a file. */
|
|
183
570
|
function targetIsFile(path: string): boolean {
|
|
184
571
|
try {
|
|
185
572
|
if (existsSync(path) && statSync(path).isDirectory()) return false;
|
|
186
|
-
} catch {
|
|
187
|
-
|
|
573
|
+
} catch {
|
|
574
|
+
/* fall through to the extension test */
|
|
575
|
+
}
|
|
576
|
+
const base = basename(path);
|
|
188
577
|
return base.includes(".") && !base.startsWith(".");
|
|
189
578
|
}
|
|
190
579
|
|
|
191
|
-
function
|
|
192
|
-
if (
|
|
193
|
-
|
|
580
|
+
function resolveBool(explicit: boolean | undefined, envName: string, fallback: boolean): boolean {
|
|
581
|
+
if (explicit !== undefined) {
|
|
582
|
+
if (typeof explicit !== "boolean") {
|
|
583
|
+
throw new LogConfigurationError(`${envName.replace("TINA4_LOG_", "").toLowerCase()} must be a boolean`, {
|
|
584
|
+
setting: envName,
|
|
585
|
+
value: explicit,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return explicit;
|
|
589
|
+
}
|
|
590
|
+
const raw = process.env[envName];
|
|
591
|
+
if (raw === undefined) return fallback;
|
|
592
|
+
const key = raw.trim().toLowerCase();
|
|
593
|
+
const truthy = ["1", "true", "on", "yes", "y", "t"];
|
|
594
|
+
const falsy = ["0", "false", "off", "no", "n", "f", ""];
|
|
595
|
+
if (truthy.includes(key)) return true;
|
|
596
|
+
if (falsy.includes(key)) return false;
|
|
597
|
+
throw new LogConfigurationError(`${envName}=${JSON.stringify(raw)} is not a valid boolean`, {
|
|
598
|
+
setting: envName,
|
|
599
|
+
value: raw,
|
|
600
|
+
accepted: [...truthy, ...falsy],
|
|
601
|
+
});
|
|
194
602
|
}
|
|
195
603
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
*
|
|
209
|
-
* TINA4_DEBUG still decides COLOUR — a terminal concern, not a format one — so
|
|
210
|
-
* a production pipe gets clean uncoloured bytes and a dev terminal stays
|
|
211
|
-
* readable.
|
|
212
|
-
*
|
|
213
|
-
* Default file-output rule (TINA4_LOG_OUTPUT unset): the log FILE is written
|
|
214
|
-
* only in development. An explicit TINA4_LOG_OUTPUT=file/both, OR an explicit
|
|
215
|
-
* TINA4_LOG_FILE path, always forces a file (explicit wins). stdout is ALWAYS on.
|
|
216
|
-
*
|
|
217
|
-
* Env vars:
|
|
218
|
-
* TINA4_LOG_FILE — explicit log file (absolute or relative). Setting it forces a file even in production. Empty = use TINA4_LOG_DIR + tina4.log
|
|
219
|
-
* TINA4_LOG_DIR — directory for log files (default: "logs")
|
|
220
|
-
* TINA4_LOG_FORMAT — "text" | "json" (default: "text") — the ONLY format switch
|
|
221
|
-
* TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout" → file only in dev)
|
|
222
|
-
* TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
|
|
223
|
-
* TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
|
|
224
|
-
* TINA4_LOG_LEVEL — minimum console level: DEBUG | INFO | WARNING | ERROR | CRITICAL (default: "INFO")
|
|
225
|
-
* TINA4_LOG_STRICT — truthy: a log-write failure THROWS instead of being swallowed (default: off)
|
|
226
|
-
*
|
|
227
|
-
* Every one of these is read LAZILY, on each log() call — a script, worker, CLI
|
|
228
|
-
* tool or test that never boots a server still gets the operator's configuration.
|
|
229
|
-
*
|
|
230
|
-
* Rotation is stdlib roll-your-own:
|
|
231
|
-
* - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
|
|
232
|
-
* - app.log.{N-1} → app.log.{N}, …, app.log → app.log.1 via fs.renameSync.
|
|
233
|
-
* - Files beyond _KEEP are dropped via fs.unlinkSync.
|
|
234
|
-
* - _SIZE=0 disables rotation entirely.
|
|
235
|
-
*/
|
|
236
|
-
export class Log {
|
|
237
|
-
private static requestId: string | undefined;
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* What configure() was explicitly told, held HERE rather than written back
|
|
241
|
-
* into process.env (ADR-0041).
|
|
242
|
-
*
|
|
243
|
-
* configure() used to assign to process.env.TINA4_LOG_DIR / _LOG_FILE. That
|
|
244
|
-
* reached the right answer -- the argument won -- through a mechanism no
|
|
245
|
-
* other framework has: it DESTROYED the operator's value for the rest of the
|
|
246
|
-
* process, and every child process spawned afterwards inherited the
|
|
247
|
-
* argument instead of what the operator set. Reading configuration must not
|
|
248
|
-
* write it. Keeping the explicit values in their own slot means resolution
|
|
249
|
-
* is explicit > env > default with the environment left intact and still
|
|
250
|
-
* readable.
|
|
251
|
-
*/
|
|
252
|
-
private static explicitLogDir: string | undefined;
|
|
253
|
-
private static explicitLogFile: string | undefined;
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Re-read all log-related env vars. Called on every log() so tests that
|
|
257
|
-
* mutate process.env between calls see the new values without having to
|
|
258
|
-
* call configure() each time.
|
|
259
|
-
*/
|
|
260
|
-
private static readEnv(): {
|
|
261
|
-
logDir: string;
|
|
262
|
-
logFile: string;
|
|
263
|
-
rotateSize: number;
|
|
264
|
-
rotateKeep: number;
|
|
265
|
-
minLevel: number;
|
|
266
|
-
format: "text" | "json";
|
|
267
|
-
output: "stdout" | "file" | "both";
|
|
268
|
-
fileEnabled: boolean;
|
|
269
|
-
/** True when the operator named ONE file, so no error.log sibling appears. */
|
|
270
|
-
explicitFile: boolean;
|
|
271
|
-
/** TINA4_LOG_STRICT — a failed log write throws instead of being swallowed. */
|
|
272
|
-
strict: boolean;
|
|
273
|
-
} {
|
|
274
|
-
// explicit argument > environment > default (ADR-0041).
|
|
275
|
-
const logDir = Log.explicitLogDir ?? process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
|
|
276
|
-
const explicitFile = (Log.explicitLogFile ?? process.env.TINA4_LOG_FILE ?? "").trim();
|
|
277
|
-
const logFile = explicitFile || DEFAULT_LOG_FILE;
|
|
278
|
-
|
|
279
|
-
const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
|
|
280
|
-
let rotateSize = DEFAULT_ROTATE_SIZE;
|
|
281
|
-
if (rawSize !== undefined) {
|
|
282
|
-
const n = parseInt(rawSize, 10);
|
|
283
|
-
rotateSize = isNaN(n) || n < 0 ? DEFAULT_ROTATE_SIZE : n;
|
|
604
|
+
function resolveInt(
|
|
605
|
+
explicit: number | undefined,
|
|
606
|
+
envName: string,
|
|
607
|
+
fallback: number,
|
|
608
|
+
minimum: number,
|
|
609
|
+
): number {
|
|
610
|
+
if (explicit !== undefined) {
|
|
611
|
+
if (!Number.isInteger(explicit) || explicit < minimum) {
|
|
612
|
+
throw new LogConfigurationError(`${envName} must be an integer >= ${minimum}`, {
|
|
613
|
+
setting: envName,
|
|
614
|
+
value: explicit,
|
|
615
|
+
});
|
|
284
616
|
}
|
|
617
|
+
return explicit;
|
|
618
|
+
}
|
|
619
|
+
const raw = process.env[envName];
|
|
620
|
+
if (raw === undefined) return fallback;
|
|
621
|
+
if (!/^-?\d+$/.test(raw.trim())) {
|
|
622
|
+
throw new LogConfigurationError(`${envName}=${JSON.stringify(raw)} is not an integer`, {
|
|
623
|
+
setting: envName,
|
|
624
|
+
value: raw,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const n = parseInt(raw, 10);
|
|
628
|
+
if (n < minimum) {
|
|
629
|
+
throw new LogConfigurationError(`${envName}=${n} must be >= ${minimum}`, { setting: envName, value: n });
|
|
630
|
+
}
|
|
631
|
+
return n;
|
|
632
|
+
}
|
|
285
633
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
634
|
+
/** rotateKeep allows 0 (Decision: rotate_keep=0 discards the old current
|
|
635
|
+
* entirely rather than renaming it to .1) but never negative or fractional. */
|
|
636
|
+
function resolveRotateKeep(explicit: number | undefined, fallback: number): number {
|
|
637
|
+
if (explicit !== undefined) {
|
|
638
|
+
if (!Number.isInteger(explicit) || explicit < 0) {
|
|
639
|
+
throw new LogConfigurationError("rotateKeep must be a non-negative integer", {
|
|
640
|
+
setting: "TINA4_LOG_ROTATE_KEEP",
|
|
641
|
+
value: explicit,
|
|
642
|
+
});
|
|
291
643
|
}
|
|
644
|
+
return explicit;
|
|
645
|
+
}
|
|
646
|
+
const raw = process.env.TINA4_LOG_ROTATE_KEEP;
|
|
647
|
+
if (raw === undefined) return fallback;
|
|
648
|
+
if (!/^-?\d+$/.test(raw.trim())) {
|
|
649
|
+
throw new LogConfigurationError(`TINA4_LOG_ROTATE_KEEP=${JSON.stringify(raw)} is not an integer`, {
|
|
650
|
+
setting: "TINA4_LOG_ROTATE_KEEP",
|
|
651
|
+
value: raw,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const n = parseInt(raw, 10);
|
|
655
|
+
if (n < 0) {
|
|
656
|
+
throw new LogConfigurationError(`TINA4_LOG_ROTATE_KEEP=${n} must not be negative`, {
|
|
657
|
+
setting: "TINA4_LOG_ROTATE_KEEP",
|
|
658
|
+
value: n,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return n;
|
|
662
|
+
}
|
|
292
663
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const out = (process.env.TINA4_LOG_OUTPUT ?? "stdout").trim().toLowerCase();
|
|
302
|
-
let output: "stdout" | "file" | "both" = "stdout";
|
|
303
|
-
if (out === "file") output = "file";
|
|
304
|
-
else if (out === "both") output = "both";
|
|
305
|
-
|
|
306
|
-
// v3.13.39: dev/prod-aware default file output (Python master, 4c6d881).
|
|
307
|
-
// When TINA4_LOG_OUTPUT is unset (default "stdout"), the log FILE is written
|
|
308
|
-
// only in development (TINA4_DEBUG truthy). In production / containers the
|
|
309
|
-
// logger is stdout-only — writing logs/tina4.log inside a container just
|
|
310
|
-
// bloats the writable layer + disk, and 12-factor wants logs on stdout for
|
|
311
|
-
// the platform to capture. Explicit TINA4_LOG_OUTPUT=file/both OR an explicit
|
|
312
|
-
// TINA4_LOG_FILE path always wins (explicit forces a file regardless of env).
|
|
313
|
-
let fileEnabled: boolean;
|
|
314
|
-
if (output === "file" || output === "both") {
|
|
315
|
-
fileEnabled = true;
|
|
316
|
-
} else if (explicitFile !== "") {
|
|
317
|
-
fileEnabled = true;
|
|
318
|
-
} else {
|
|
319
|
-
fileEnabled = !Log.isProduction();
|
|
664
|
+
function checkRemovedSettings(): void {
|
|
665
|
+
for (const [name, detail] of Object.entries(REMOVED_SETTINGS)) {
|
|
666
|
+
if (process.env[name] !== undefined) {
|
|
667
|
+
throw new LogConfigurationError(`${name} is a removed setting (${detail})`, {
|
|
668
|
+
setting: name,
|
|
669
|
+
value: process.env[name],
|
|
670
|
+
});
|
|
320
671
|
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
321
674
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// log directory is read-only fails loudly instead of running blind.
|
|
326
|
-
const strict = isTruthy(process.env.TINA4_LOG_STRICT);
|
|
675
|
+
function isProduction(): boolean {
|
|
676
|
+
return !isTruthy(process.env.TINA4_DEBUG);
|
|
677
|
+
}
|
|
327
678
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
679
|
+
/**
|
|
680
|
+
* Resolve one fully-validated configuration snapshot from explicit options,
|
|
681
|
+
* then environment, then default (ADR-0041) — WITHOUT touching the
|
|
682
|
+
* filesystem. Every invalid setting throws LogConfigurationError before this
|
|
683
|
+
* function returns, so a caller (configure() or the lazy first-use path)
|
|
684
|
+
* commits nothing on a validation failure (LOG-C07, LOG-V01..V05).
|
|
685
|
+
*/
|
|
686
|
+
function resolveSnapshot(options: ConfigureOptions): Omit<Snapshot, "mainSink" | "errorSink"> {
|
|
687
|
+
checkRemovedSettings();
|
|
688
|
+
|
|
689
|
+
const level = options.level !== undefined
|
|
690
|
+
? parseLevel(options.level, "level")
|
|
691
|
+
: process.env.TINA4_LOG_LEVEL !== undefined
|
|
692
|
+
? parseLevel(process.env.TINA4_LOG_LEVEL, "TINA4_LOG_LEVEL")
|
|
693
|
+
: DEFAULT_LEVEL;
|
|
694
|
+
|
|
695
|
+
const fileLevel = options.fileLevel !== undefined
|
|
696
|
+
? parseLevel(options.fileLevel, "fileLevel")
|
|
697
|
+
: process.env.TINA4_LOG_FILE_LEVEL !== undefined
|
|
698
|
+
? parseLevel(process.env.TINA4_LOG_FILE_LEVEL, "TINA4_LOG_FILE_LEVEL")
|
|
699
|
+
: DEFAULT_FILE_LEVEL;
|
|
700
|
+
|
|
701
|
+
// Format is DEBUG-DERIVED (Decision 3): explicit TINA4_LOG_FORMAT wins;
|
|
702
|
+
// otherwise truthy TINA4_DEBUG selects text, else json.
|
|
703
|
+
let format: "text" | "json";
|
|
704
|
+
const explicitFormat = options.format ?? process.env.TINA4_LOG_FORMAT;
|
|
705
|
+
if (explicitFormat !== undefined) {
|
|
706
|
+
const f = explicitFormat.trim().toLowerCase();
|
|
707
|
+
if (f !== "text" && f !== "json") {
|
|
708
|
+
throw new LogConfigurationError(`TINA4_LOG_FORMAT=${JSON.stringify(explicitFormat)} is not valid`, {
|
|
709
|
+
setting: "TINA4_LOG_FORMAT",
|
|
710
|
+
value: explicitFormat,
|
|
711
|
+
accepted: ["text", "json"],
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
format = f;
|
|
715
|
+
} else {
|
|
716
|
+
format = isTruthy(process.env.TINA4_DEBUG) ? "text" : "json";
|
|
333
717
|
}
|
|
334
718
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
719
|
+
// Output: an explicit value (stdout/file/both) ALWAYS wins, full stop —
|
|
720
|
+
// naming a file (below) never itself enables the file sink (LOG-C08).
|
|
721
|
+
let outputSelector: "stdout" | "file" | "both";
|
|
722
|
+
const explicitOutput = options.output ?? process.env.TINA4_LOG_OUTPUT;
|
|
723
|
+
if (explicitOutput !== undefined) {
|
|
724
|
+
const o = explicitOutput.trim().toLowerCase();
|
|
725
|
+
if (o !== "stdout" && o !== "file" && o !== "both") {
|
|
726
|
+
throw new LogConfigurationError(`TINA4_LOG_OUTPUT=${JSON.stringify(explicitOutput)} is not valid`, {
|
|
727
|
+
setting: "TINA4_LOG_OUTPUT",
|
|
728
|
+
value: explicitOutput,
|
|
729
|
+
accepted: ["stdout", "file", "both"],
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
outputSelector = o;
|
|
733
|
+
} else {
|
|
734
|
+
// Unset: dev/prod-aware default — file only in development.
|
|
735
|
+
outputSelector = isProduction() ? "stdout" : "both";
|
|
343
736
|
}
|
|
737
|
+
const stdoutEnabled = outputSelector !== "file";
|
|
738
|
+
const fileEnabled = outputSelector !== "stdout";
|
|
344
739
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const cfg = Log.readEnv();
|
|
365
|
-
const lvl = (level ?? "").toUpperCase() as LogLevel;
|
|
366
|
-
return Log.passesThreshold(lvl, cfg.minLevel);
|
|
740
|
+
const rotateSize = resolveInt(options.rotateSize, "TINA4_LOG_ROTATE_SIZE", DEFAULT_ROTATE_SIZE, MIN_ROTATE_SIZE);
|
|
741
|
+
const rotateKeep = resolveRotateKeep(options.rotateKeep, DEFAULT_ROTATE_KEEP);
|
|
742
|
+
const strict = resolveBool(options.strict, "TINA4_LOG_STRICT", false);
|
|
743
|
+
const callerCapture = resolveBool(options.caller, "TINA4_LOG_FUNC", false);
|
|
744
|
+
|
|
745
|
+
const dirRaw = options.logDir ?? process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
|
|
746
|
+
if (dirRaw === "") {
|
|
747
|
+
throw new LogConfigurationError("logDir must not be empty", { setting: "TINA4_LOG_DIR", value: dirRaw });
|
|
748
|
+
}
|
|
749
|
+
const fileRaw = options.logFile ?? process.env.TINA4_LOG_FILE ?? "";
|
|
750
|
+
// A NUL byte can never be part of a real path (the underlying syscalls
|
|
751
|
+
// reject it), but that would otherwise only surface at OPEN time -- and
|
|
752
|
+
// only when the file sink ends up enabled. Reject it here, unconditionally,
|
|
753
|
+
// so a malformed path fails CONFIGURATION regardless of whether output
|
|
754
|
+
// happens to resolve to a sink that would ever touch the filesystem.
|
|
755
|
+
if (dirRaw.includes("\0") || fileRaw.includes("\0")) {
|
|
756
|
+
throw new LogConfigurationError("log path must not contain a NUL byte", {
|
|
757
|
+
setting: dirRaw.includes("\0") ? "TINA4_LOG_DIR" : "TINA4_LOG_FILE",
|
|
758
|
+
});
|
|
367
759
|
}
|
|
368
760
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
761
|
+
const projectRoot = process.cwd();
|
|
762
|
+
let dirCandidate = dirRaw;
|
|
763
|
+
let fileCandidate = fileRaw;
|
|
764
|
+
if (!fileCandidate && targetIsFile(dirCandidate)) {
|
|
765
|
+
fileCandidate = basename(dirCandidate);
|
|
766
|
+
dirCandidate = dirname(dirCandidate);
|
|
374
767
|
}
|
|
375
768
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
769
|
+
const resolvedLogDir = (isAbsolute(dirCandidate) ? dirCandidate : join(projectRoot, dirCandidate)).replace(/\/$/, "");
|
|
770
|
+
|
|
771
|
+
let resolvedLogFile: string | null;
|
|
772
|
+
let layout: "directory" | "single";
|
|
773
|
+
if (fileCandidate) {
|
|
774
|
+
resolvedLogFile = isAbsolute(fileCandidate) ? fileCandidate : join(resolvedLogDir, fileCandidate);
|
|
775
|
+
layout = "single";
|
|
776
|
+
} else {
|
|
777
|
+
resolvedLogFile = null;
|
|
778
|
+
layout = "directory";
|
|
381
779
|
}
|
|
382
780
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
781
|
+
return {
|
|
782
|
+
level,
|
|
783
|
+
fileLevel,
|
|
784
|
+
format,
|
|
785
|
+
stdoutEnabled,
|
|
786
|
+
fileEnabled,
|
|
787
|
+
outputSelector,
|
|
788
|
+
logDir: resolvedLogDir,
|
|
789
|
+
logFile: resolvedLogFile,
|
|
790
|
+
layout,
|
|
791
|
+
rotateSize,
|
|
792
|
+
rotateKeep,
|
|
793
|
+
strict,
|
|
794
|
+
callerCapture,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ============================================================================
|
|
799
|
+
// Log — the public class.
|
|
800
|
+
// ============================================================================
|
|
801
|
+
|
|
802
|
+
let activeSnapshot: Snapshot | null = null;
|
|
803
|
+
|
|
804
|
+
/** Lazily resolve (and CACHE) the effective configuration on first use
|
|
805
|
+
* (LOG-C01/L2). Once resolved the snapshot is STABLE: a later environment
|
|
806
|
+
* mutation does not retroactively change it (LOG-C05) until reset() (LOG-C06). */
|
|
807
|
+
function ensureSnapshot(): Snapshot {
|
|
808
|
+
if (activeSnapshot) return activeSnapshot;
|
|
809
|
+
const resolved = resolveSnapshot({});
|
|
810
|
+
activeSnapshot = openSinks(resolved);
|
|
811
|
+
return activeSnapshot;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function openSinks(resolved: Omit<Snapshot, "mainSink" | "errorSink">): Snapshot {
|
|
815
|
+
let mainSink: LogFileSink | null = null;
|
|
816
|
+
let errorSink: LogFileSink | null = null;
|
|
817
|
+
if (resolved.fileEnabled) {
|
|
818
|
+
if (resolved.layout === "single") {
|
|
819
|
+
mainSink = new LogFileSink(resolved.logFile!, resolved.rotateSize, resolved.rotateKeep);
|
|
820
|
+
mainSink.open();
|
|
821
|
+
} else {
|
|
822
|
+
mainSink = new LogFileSink(join(resolved.logDir, DEFAULT_LOG_FILE_NAME), resolved.rotateSize, resolved.rotateKeep);
|
|
823
|
+
mainSink.open();
|
|
824
|
+
errorSink = new LogFileSink(join(resolved.logDir, "error.log"), resolved.rotateSize, resolved.rotateKeep);
|
|
825
|
+
errorSink.open();
|
|
415
826
|
}
|
|
416
|
-
Log.applyAppendMode();
|
|
417
827
|
}
|
|
828
|
+
return { ...resolved, mainSink, errorSink };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function stdoutIsTty(): boolean {
|
|
832
|
+
return process.stdout.isTTY === true;
|
|
833
|
+
}
|
|
418
834
|
|
|
835
|
+
export class Log {
|
|
419
836
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
837
|
+
* Configure the logger. Two-phase and transactional: the WHOLE candidate
|
|
838
|
+
* configuration is resolved and validated first (LOG-C07 — a bad setting
|
|
839
|
+
* never touches the filesystem and never disturbs the prior snapshot),
|
|
840
|
+
* then its sinks are opened (LOG-E01 — an inaccessible sink fails
|
|
841
|
+
* configuration, still without replacing the prior snapshot), and only on
|
|
842
|
+
* full success does it become the active configuration.
|
|
422
843
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* re-point the logs) and wrong for a long-lived test process that wants to
|
|
428
|
-
* drive the logger purely from the environment afterwards.
|
|
844
|
+
* Log.configure() -> logs/tina4.log + logs/error.log
|
|
845
|
+
* Log.configure({ logDir: "/var/log/myapp" }) -> /var/log/myapp/tina4.log + error.log
|
|
846
|
+
* Log.configure({ logFile: "/var/log/myapp/app.log" }) -> that exact file, no error.log sibling
|
|
847
|
+
* Log.configure({ level: "debug", format: "text", output: "both", strict: true, caller: true })
|
|
429
848
|
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* accident. test/logger.test.ts depends on exactly that -- it configures a
|
|
435
|
-
* file early, then runs the whole default-output block off the environment
|
|
436
|
-
* (see its own note: "these cases must NOT route through Log.configure").
|
|
437
|
-
* Three of those cases failed on the lab until this came back.
|
|
438
|
-
*/
|
|
439
|
-
static reset(): void {
|
|
440
|
-
Log.explicitLogDir = undefined;
|
|
441
|
-
Log.explicitLogFile = undefined;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* TINA4_LOG_APPEND — append (default) or overwrite on startup.
|
|
849
|
+
* A plain string is also accepted as shorthand for `{ logDir: string }` —
|
|
850
|
+
* or `{ logFile: string }` when it looks like a file (has an extension and
|
|
851
|
+
* is not an existing directory), via the same targetIsFile heuristic
|
|
852
|
+
* configure() itself uses to split a bare TINA4_LOG_DIR that names a file.
|
|
446
853
|
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
* container shipping logs elsewhere); the files are truncated once here at
|
|
450
|
-
* configure time, never per line.
|
|
854
|
+
* Log.configure("/var/log/myapp") -> same as { logDir: "/var/log/myapp" }
|
|
855
|
+
* Log.configure("/var/log/myapp/app.log") -> same as { logFile: "/var/log/myapp/app.log" }
|
|
451
856
|
*/
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
? [resolveLogFilePath(cfg.logDir, cfg.logFile)]
|
|
459
|
-
: [resolveLogFilePath(cfg.logDir, cfg.logFile), join(cfg.logDir, "error.log")];
|
|
460
|
-
for (const path of targets) {
|
|
461
|
-
try {
|
|
462
|
-
if (existsSync(path)) writeFileSync(path, "", "utf-8");
|
|
463
|
-
} catch (err) {
|
|
464
|
-
// Same TINA4_LOG_STRICT contract as writeToFile: this IS a write to the
|
|
465
|
-
// log file, so under strict it must not be swallowed either.
|
|
466
|
-
if (cfg.strict) throw err;
|
|
467
|
-
/* logging must never crash the app */
|
|
468
|
-
}
|
|
469
|
-
}
|
|
857
|
+
static configure(options: string | ConfigureOptions = {}): void {
|
|
858
|
+
const normalized: ConfigureOptions = typeof options === "string"
|
|
859
|
+
? (targetIsFile(options) ? { logFile: options } : { logDir: options })
|
|
860
|
+
: options;
|
|
861
|
+
const resolved = resolveSnapshot(normalized);
|
|
862
|
+
activeSnapshot = openSinks(resolved);
|
|
470
863
|
}
|
|
471
864
|
|
|
472
|
-
/**
|
|
473
|
-
|
|
474
|
-
|
|
865
|
+
/** Forget the active configuration; the next use resolves fresh from the
|
|
866
|
+
* environment. Idempotent. */
|
|
867
|
+
static reset(): void {
|
|
868
|
+
activeSnapshot = null;
|
|
869
|
+
requestIdStore.disable?.();
|
|
870
|
+
requestIdFallback = undefined;
|
|
475
871
|
}
|
|
476
872
|
|
|
477
|
-
/**
|
|
478
|
-
static
|
|
479
|
-
|
|
873
|
+
/** A defensive copy of the effective, stable configuration (LOG-C10). */
|
|
874
|
+
static configuration(): Record<string, unknown> {
|
|
875
|
+
const s = ensureSnapshot();
|
|
876
|
+
return {
|
|
877
|
+
level: s.level,
|
|
878
|
+
file_level: s.fileLevel,
|
|
879
|
+
format: s.format,
|
|
880
|
+
output: s.outputSelector,
|
|
881
|
+
log_dir: s.logDir,
|
|
882
|
+
log_file: s.logFile,
|
|
883
|
+
layout: s.layout,
|
|
884
|
+
rotate_size: s.rotateSize,
|
|
885
|
+
rotate_keep: s.rotateKeep,
|
|
886
|
+
strict: s.strict,
|
|
887
|
+
caller: s.callerCapture,
|
|
888
|
+
stdout_enabled: s.stdoutEnabled,
|
|
889
|
+
file_enabled: s.fileEnabled,
|
|
890
|
+
};
|
|
480
891
|
}
|
|
481
892
|
|
|
482
|
-
|
|
483
|
-
static warning(message: unknown, data?: unknown): void {
|
|
484
|
-
Log.log("WARNING", message, data);
|
|
485
|
-
}
|
|
893
|
+
// ── request id (Decision 12 — AsyncLocalStorage) ─────────────────────
|
|
486
894
|
|
|
487
|
-
/**
|
|
488
|
-
|
|
489
|
-
|
|
895
|
+
/** Run `fn` with `id` established as the request-scoped correlation id.
|
|
896
|
+
* Every log line inside `fn` — across every await — carries `id`, and two
|
|
897
|
+
* concurrent requests each keep their own; the id needs no explicit clear
|
|
898
|
+
* because it is scoped to this call, not to shared mutable state. */
|
|
899
|
+
static runWithRequestId<T>(id: string | undefined, fn: () => T): T {
|
|
900
|
+
return requestIdStore.run({ id }, fn);
|
|
490
901
|
}
|
|
491
902
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
903
|
+
static setRequestId(id: string | undefined): void {
|
|
904
|
+
const store = requestIdStore.getStore();
|
|
905
|
+
if (store) store.id = id;
|
|
906
|
+
else requestIdFallback = id;
|
|
495
907
|
}
|
|
496
908
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
* console threshold, which it always passes at normal levels — and is always
|
|
501
|
-
* persisted to the log file (Node tees every level to a single tina4.log;
|
|
502
|
-
* critical 4 >= warning 2 so it would be in error.log on a split-file model).
|
|
503
|
-
* Matches Python master parity — there is no enable toggle.
|
|
504
|
-
*/
|
|
505
|
-
static critical(message: unknown, data?: unknown): void {
|
|
506
|
-
Log.log("CRITICAL", message, data);
|
|
909
|
+
static getRequestId(): string | undefined {
|
|
910
|
+
const store = requestIdStore.getStore();
|
|
911
|
+
return store ? store.id : requestIdFallback;
|
|
507
912
|
}
|
|
508
913
|
|
|
509
|
-
/**
|
|
510
|
-
|
|
511
|
-
|
|
914
|
+
/** Explicitly end a request's correlation scope (LOG-Q01/A01 public
|
|
915
|
+
* surface). Equivalent to setRequestId(undefined) but named for parity
|
|
916
|
+
* with the other three frameworks' clear_request_id. */
|
|
917
|
+
static clearRequestId(): void {
|
|
918
|
+
Log.setRequestId(undefined);
|
|
512
919
|
}
|
|
513
920
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
921
|
+
static sanitizeRequestId(value: string | string[] | undefined | null): string | undefined {
|
|
922
|
+
if (value === undefined || value === null) return undefined;
|
|
923
|
+
const raw = Array.isArray(value) ? value.join(",") : value;
|
|
924
|
+
if (raw.length === 0 || raw.length > 128) return undefined;
|
|
925
|
+
if (/[^A-Za-z0-9._-]/.test(raw)) return undefined;
|
|
926
|
+
return raw;
|
|
517
927
|
}
|
|
518
928
|
|
|
519
|
-
|
|
520
|
-
private static ensureLogDir(filePath: string): void {
|
|
521
|
-
const dir = dirname(filePath);
|
|
522
|
-
if (!existsSync(dir)) {
|
|
523
|
-
mkdirSync(dir, { recursive: true });
|
|
524
|
-
}
|
|
525
|
-
}
|
|
929
|
+
// ── threshold ──────────────────────────────────────────────────────
|
|
526
930
|
|
|
527
931
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* 2. Drop any file beyond keep via unlinkSync (cap the historical count).
|
|
533
|
-
* 3. Atomic shift: app.log.{N-1} → app.log.{N}, …, app.log.1 → app.log.2.
|
|
534
|
-
* 4. Rename current app.log → app.log.1.
|
|
535
|
-
* 5. Truncate via writeFileSync(path, "") so subsequent appends start fresh.
|
|
536
|
-
*
|
|
537
|
-
* Sync calls per write are fine — the worst case is contention on a single
|
|
538
|
-
* file, and the OS atomically serialises rename/unlink anyway.
|
|
539
|
-
*
|
|
540
|
-
* `rotateSize` of 0 disables rotation entirely.
|
|
932
|
+
* True when `level` passes the queried sink's threshold and that sink is
|
|
933
|
+
* active. `sink` is undefined/"console"/"stdout" (console — the historical
|
|
934
|
+
* meaning) or "file" (Decision 8: the file sink has its own independent
|
|
935
|
+
* TINA4_LOG_FILE_LEVEL threshold).
|
|
541
936
|
*/
|
|
542
|
-
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
let size = 0;
|
|
547
|
-
try {
|
|
548
|
-
size = statSync(filePath).size;
|
|
549
|
-
} catch {
|
|
550
|
-
return;
|
|
937
|
+
static isEnabled(level: string, sink?: "console" | "stdout" | "file"): boolean {
|
|
938
|
+
if (level === undefined || level === null) {
|
|
939
|
+
throw new LogArgumentError("isEnabled requires a level", { argument: "level" });
|
|
551
940
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
try { unlinkSync(stale); } catch { /* ignore */ }
|
|
559
|
-
} else {
|
|
560
|
-
break;
|
|
561
|
-
}
|
|
941
|
+
const key = String(level).trim().toUpperCase() as LogLevel;
|
|
942
|
+
if (!(key in LEVELS)) {
|
|
943
|
+
throw new LogArgumentError(`${JSON.stringify(level)} is not a valid level`, {
|
|
944
|
+
argument: "level",
|
|
945
|
+
accepted: Object.keys(LEVELS),
|
|
946
|
+
});
|
|
562
947
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (existsSync(oldest)) {
|
|
567
|
-
try { unlinkSync(oldest); } catch { /* ignore */ }
|
|
948
|
+
const s = ensureSnapshot();
|
|
949
|
+
if (sink === undefined || sink === "console" || sink === "stdout") {
|
|
950
|
+
return s.stdoutEnabled && LEVELS[key] >= LEVELS[s.level];
|
|
568
951
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
for (let n = rotateKeep - 1; n >= 1; n--) {
|
|
572
|
-
const src = `${filePath}.${n}`;
|
|
573
|
-
const dst = `${filePath}.${n + 1}`;
|
|
574
|
-
if (existsSync(src)) {
|
|
575
|
-
try { renameSync(src, dst); } catch { /* ignore */ }
|
|
576
|
-
}
|
|
952
|
+
if (sink === "file") {
|
|
953
|
+
return s.fileEnabled && LEVELS[key] >= LEVELS[s.fileLevel];
|
|
577
954
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
955
|
+
throw new LogArgumentError(`${JSON.stringify(sink)} is not a valid sink`, {
|
|
956
|
+
argument: "sink",
|
|
957
|
+
accepted: ["console", "file"],
|
|
958
|
+
});
|
|
582
959
|
}
|
|
583
960
|
|
|
584
|
-
|
|
585
|
-
* Write a line to the log file, stripping ANSI codes.
|
|
586
|
-
*
|
|
587
|
-
* A failure is swallowed by default — logging must never crash the app. With
|
|
588
|
-
* TINA4_LOG_STRICT truthy it is RE-THROWN instead: an app that believes it is
|
|
589
|
-
* writing an audit trail into a read-only directory, and is not, is worse off
|
|
590
|
-
* than one that dies at the first line. Same contract in all four frameworks.
|
|
591
|
-
*/
|
|
592
|
-
private static writeToFile(
|
|
593
|
-
filePath: string,
|
|
594
|
-
line: string,
|
|
595
|
-
rotateSize: number,
|
|
596
|
-
rotateKeep: number,
|
|
597
|
-
strict: boolean,
|
|
598
|
-
): void {
|
|
599
|
-
try {
|
|
600
|
-
Log.ensureLogDir(filePath);
|
|
601
|
-
Log.rotateIfNeeded(filePath, rotateSize, rotateKeep);
|
|
602
|
-
appendFileSync(filePath, stripAnsi(line) + "\n", "utf-8");
|
|
603
|
-
} catch (err) {
|
|
604
|
-
if (strict) throw err;
|
|
605
|
-
// Silently fail — logging should never crash the app
|
|
606
|
-
}
|
|
607
|
-
}
|
|
961
|
+
// ── event methods (Decision 23) ──────────────────────────────────────
|
|
608
962
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
963
|
+
static debug(message: unknown, context?: unknown): void {
|
|
964
|
+
Log.emit("DEBUG", message, context);
|
|
965
|
+
}
|
|
612
966
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const message = coerceMessage(rawMessage);
|
|
967
|
+
static info(message: unknown, context?: unknown): void {
|
|
968
|
+
Log.emit("INFO", message, context);
|
|
969
|
+
}
|
|
617
970
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
message,
|
|
622
|
-
};
|
|
971
|
+
static warning(message: unknown, context?: unknown): void {
|
|
972
|
+
Log.emit("WARNING", message, context);
|
|
973
|
+
}
|
|
623
974
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
975
|
+
static error(message: unknown, context?: unknown): void {
|
|
976
|
+
Log.emit("ERROR", message, context);
|
|
977
|
+
}
|
|
627
978
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
entry.function = fnName;
|
|
634
|
-
}
|
|
979
|
+
/** Highest severity. Always emits, subject only to the configured
|
|
980
|
+
* threshold — there is no separate enable toggle. */
|
|
981
|
+
static critical(message: unknown, context?: unknown): void {
|
|
982
|
+
Log.emit("CRITICAL", message, context);
|
|
983
|
+
}
|
|
635
984
|
|
|
636
|
-
|
|
637
|
-
|
|
985
|
+
private static emit(level: EventLevel, rawMessage: unknown, rawContext: unknown): void {
|
|
986
|
+
const s = ensureSnapshot();
|
|
987
|
+
const consoleOk = s.stdoutEnabled && LEVELS[level] >= LEVELS[s.level];
|
|
988
|
+
const fileOk = s.fileEnabled && LEVELS[level] >= LEVELS[s.fileLevel];
|
|
989
|
+
if (!consoleOk && !fileOk) return;
|
|
990
|
+
|
|
991
|
+
const requestId = Log.getRequestId();
|
|
992
|
+
const callerName = s.callerCapture ? resolveCallerName() : undefined;
|
|
993
|
+
const event = buildEvent(level, rawMessage, requestId, callerName, rawContext);
|
|
994
|
+
|
|
995
|
+
if (consoleOk) {
|
|
996
|
+
const line = boundedForSink(event, s.format, STDOUT_MAX_BYTES).replace(/\n$/, "");
|
|
997
|
+
const plain = s.format === "json" || !stdoutIsTty();
|
|
998
|
+
const text = plain ? line : `${COLORS[level]}${line}${RESET}`;
|
|
999
|
+
// console.log (not a direct process.stdout.write) — interceptable by
|
|
1000
|
+
// reassigning console.log, which is how both this codebase's own tests
|
|
1001
|
+
// and application code conventionally capture/redirect log output; it
|
|
1002
|
+
// still ultimately writes to stdout when not overridden.
|
|
1003
|
+
console.log(text);
|
|
638
1004
|
}
|
|
639
1005
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${message}${dataPart}`;
|
|
646
|
-
|
|
647
|
-
// ONE format decision, both sinks, one input: TINA4_LOG_FORMAT. Text is the
|
|
648
|
-
// default; only an explicit `json` selects JSON. The `|| Log.isProduction()`
|
|
649
|
-
// that used to live here made an UNSET TINA4_DEBUG silently reformat every
|
|
650
|
-
// line — the owner's measured defect (four frameworks, four meanings of
|
|
651
|
-
// "production", four different formats off one .env). An object passed as
|
|
652
|
-
// the message is still JSON-encoded INLINE by coerceMessage, which is the
|
|
653
|
-
// only structure a default install emits.
|
|
654
|
-
const formattedLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;
|
|
655
|
-
|
|
656
|
-
const shouldLog = Log.passesThreshold(level, cfg.minLevel);
|
|
657
|
-
|
|
658
|
-
// Console output. v3.13.14: stdout is NOT suppressed in production —
|
|
659
|
-
// containers read PID 1 stdout (docker logs / k8s) and the old
|
|
660
|
-
// `!isProduction()` gate meant deployed apps logged nothing.
|
|
661
|
-
// TINA4_LOG_OUTPUT="file" still opts out of stdout entirely.
|
|
662
|
-
//
|
|
663
|
-
// stdout carries the SAME formattedLine as the file — the format env is the
|
|
664
|
-
// only thing that picks text vs JSON. TINA4_DEBUG decides COLOUR only: ANSI
|
|
665
|
-
// is for a human at a terminal, and a production pipe / log shipper must get
|
|
666
|
-
// clean bytes. Truncate on the CONSOLE only; the file keeps the full line so
|
|
667
|
-
// a consumer parsing it loses nothing, and a terminal does not need 10MB.
|
|
668
|
-
if (shouldLog && cfg.output !== "file") {
|
|
669
|
-
const consoleLine = truncateForStdout(formattedLine);
|
|
670
|
-
if (Log.isProduction()) {
|
|
671
|
-
console.log(consoleLine);
|
|
672
|
-
} else {
|
|
673
|
-
console.log(`${COLORS[level]}${consoleLine}${RESET}`);
|
|
1006
|
+
if (fileOk && s.mainSink) {
|
|
1007
|
+
const mainLine = boundedForSink(event, s.format, s.rotateSize);
|
|
1008
|
+
writeSink(s.mainSink, mainLine, s.strict);
|
|
1009
|
+
if (s.layout === "directory" && s.errorSink && LEVELS[level] >= LEVELS.WARNING) {
|
|
1010
|
+
writeSink(s.errorSink, mainLine, s.strict);
|
|
674
1011
|
}
|
|
675
1012
|
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
676
1015
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
// explicit TINA4_LOG_FILE always writes a file (explicit wins)
|
|
685
|
-
//
|
|
686
|
-
// readEnv() resolves all of the above into cfg.fileEnabled, so flipping
|
|
687
|
-
// that one flag gates the whole file writer (the only persisted sink).
|
|
688
|
-
if (cfg.fileEnabled) {
|
|
689
|
-
const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
|
|
690
|
-
Log.writeToFile(filePath, formattedLine, cfg.rotateSize, cfg.rotateKeep, cfg.strict);
|
|
691
|
-
|
|
692
|
-
// Mirror WARNING and above into a dedicated error.log so
|
|
693
|
-
// `tail -f logs/error.log` gives just the stuff worth looking at. Node
|
|
694
|
-
// wrote ONE file where Python and PHP wrote two, so anyone whose
|
|
695
|
-
// alerting tails error.log got silence here (feature 2 of the audit, D3).
|
|
696
|
-
// Skipped when the operator named ONE file explicitly: they asked for a
|
|
697
|
-
// single path, so a sibling error.log appearing beside it is a surprise.
|
|
698
|
-
if (!cfg.explicitFile && LEVEL_PRIORITY[level] >= LEVEL_PRIORITY.WARNING) {
|
|
699
|
-
const errorPath = join(cfg.logDir, "error.log");
|
|
700
|
-
Log.writeToFile(errorPath, formattedLine, cfg.rotateSize, cfg.rotateKeep, cfg.strict);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
1016
|
+
function writeSink(sink: LogFileSink, line: string, strict: boolean): void {
|
|
1017
|
+
try {
|
|
1018
|
+
sink.write(line);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
if (strict) throw err;
|
|
1021
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1022
|
+
console.log(`tina4: log sink ${sink.path} failed: ${msg}`);
|
|
703
1023
|
}
|
|
704
1024
|
}
|