@oh-my-pi/pi-utils 18.4.8 → 18.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/types/async.d.ts +6 -1
- package/dist/types/file-lock.d.ts +2 -0
- package/dist/types/logger/rotating-file.d.ts +36 -6
- package/dist/types/logger.d.ts +9 -0
- package/dist/types/marked/core.d.ts +24 -0
- package/dist/types/marked/inline-closers.d.ts +119 -0
- package/dist/types/marked/inline-text.d.ts +70 -0
- package/dist/types/math-delimiters.d.ts +22 -0
- package/dist/types/postmortem.d.ts +4 -0
- package/package.json +2 -2
- package/src/async.ts +11 -1
- package/src/file-lock.ts +8 -1
- package/src/logger/rotating-file.ts +105 -81
- package/src/logger.ts +103 -29
- package/src/marked/core.ts +234 -166
- package/src/marked/inline-closers.ts +434 -0
- package/src/marked/inline-text.ts +321 -0
- package/src/math-delimiters.ts +121 -19
- package/src/postmortem.ts +5 -0
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Process-local rotating file sink: local-day and size rotation with bounded retention. */
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { localDay } from "../dirs";
|
|
6
6
|
import { openCloexecSync } from "../fs-open";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
readonly
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
interface AuditState {
|
|
15
|
-
readonly keep: { readonly days: false; readonly amount: number };
|
|
16
|
-
readonly auditLog: string;
|
|
17
|
-
readonly files: AuditEntry[];
|
|
18
|
-
readonly hashType: "sha256";
|
|
8
|
+
/** Write batching for {@link RotatingFileSink}. */
|
|
9
|
+
export interface RotatingFileBatchOptions {
|
|
10
|
+
/** Longest a buffered record waits before an unref'd timer writes it. */
|
|
11
|
+
readonly intervalMs: number;
|
|
12
|
+
/** Buffered UTF-8 bytes at which the batch is written immediately. */
|
|
13
|
+
readonly maxBytes: number;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
/** Configuration for a process-local rotating file sink. */
|
|
@@ -23,12 +18,16 @@ export interface RotatingFileOptions {
|
|
|
23
18
|
readonly directory: string;
|
|
24
19
|
readonly filenamePrefix: string;
|
|
25
20
|
readonly filenameSuffix: string;
|
|
26
|
-
readonly auditFile: string;
|
|
27
21
|
readonly maxBytes: number;
|
|
28
22
|
readonly maxFiles: number;
|
|
23
|
+
/**
|
|
24
|
+
* Buffer records and write them together instead of one write per record.
|
|
25
|
+
* Without it every record is written as it arrives.
|
|
26
|
+
*/
|
|
27
|
+
readonly batch?: RotatingFileBatchOptions;
|
|
29
28
|
/**
|
|
30
29
|
* Called with the new active file path whenever the sink opens a different
|
|
31
|
-
* file —
|
|
30
|
+
* file — first write, local-day rotation, and size rotation alike.
|
|
32
31
|
*
|
|
33
32
|
* Consumers that hold their own descriptor on the active log (the macOS
|
|
34
33
|
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
@@ -37,50 +36,53 @@ export interface RotatingFileOptions {
|
|
|
37
36
|
readonly onRotate?: (filePath: string) => void;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Synchronous append sink with local-day and size rotation plus bounded retention.
|
|
41
|
+
*
|
|
42
|
+
* Construction performs no filesystem I/O: the first file is created when the
|
|
43
|
+
* first record is written out, so a process that never logs leaves nothing on
|
|
44
|
+
* disk. Every file name embeds the owning process's suffix (its PID), so the
|
|
45
|
+
* set of files this sink created is tracked in memory; files left by earlier
|
|
46
|
+
* processes are pruned by name pattern in the logger, not by this sink.
|
|
47
|
+
*
|
|
48
|
+
* With {@link RotatingFileOptions.batch}, records are buffered and written in
|
|
49
|
+
* one `write` per batch. Buffered bytes already count toward the active file's
|
|
50
|
+
* size, and the buffer is written to its file before any rotation, so file
|
|
51
|
+
* selection matches an unbatched sink record for record.
|
|
52
|
+
*/
|
|
47
53
|
export class RotatingFileSink {
|
|
48
54
|
readonly #directory: string;
|
|
49
55
|
readonly #filenamePrefix: string;
|
|
50
56
|
readonly #filenameSuffix: string;
|
|
51
|
-
readonly #auditFile: string;
|
|
52
57
|
readonly #maxBytes: number;
|
|
53
58
|
readonly #maxFiles: number;
|
|
59
|
+
readonly #batch: RotatingFileBatchOptions | undefined;
|
|
54
60
|
readonly #onRotate: ((filePath: string) => void) | undefined;
|
|
55
|
-
|
|
61
|
+
/** Files this sink created, oldest first. */
|
|
62
|
+
readonly #files: string[] = [];
|
|
56
63
|
#activeDay: string | undefined;
|
|
57
64
|
#activeIndex = 0;
|
|
58
65
|
#activePath: string | undefined;
|
|
66
|
+
/** Size of the active file including buffered records not yet written. */
|
|
59
67
|
#activeBytes = 0;
|
|
60
68
|
#closed = false;
|
|
61
|
-
// Held append fd:
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// rotation and closed on close()/rotation (required for Windows
|
|
65
|
-
// delete-on-prune semantics).
|
|
69
|
+
// Held append fd: one descriptor per active file instead of
|
|
70
|
+
// open+write+close per line. It is reopened on rotation and closed on
|
|
71
|
+
// close()/rotation (required for Windows delete-on-prune semantics).
|
|
66
72
|
#fd: number | undefined;
|
|
73
|
+
/** Records for the active file not yet written, each ending in EOL. */
|
|
74
|
+
#pending = "";
|
|
75
|
+
#pendingBytes = 0;
|
|
76
|
+
#flushTimer: NodeJS.Timeout | undefined;
|
|
67
77
|
|
|
68
78
|
constructor(options: RotatingFileOptions) {
|
|
69
79
|
this.#directory = options.directory;
|
|
70
80
|
this.#filenamePrefix = options.filenamePrefix;
|
|
71
81
|
this.#filenameSuffix = options.filenameSuffix;
|
|
72
|
-
this.#auditFile = options.auditFile;
|
|
73
82
|
this.#maxBytes = options.maxBytes;
|
|
74
83
|
this.#maxFiles = options.maxFiles;
|
|
84
|
+
this.#batch = options.batch;
|
|
75
85
|
this.#onRotate = options.onRotate;
|
|
76
|
-
this.#files = this.#readAudit();
|
|
77
|
-
const now = new Date();
|
|
78
|
-
this.#selectFile(localDay(now));
|
|
79
|
-
const activePath = this.#activePath;
|
|
80
|
-
if (activePath) {
|
|
81
|
-
this.#registerFile(activePath, now.getTime());
|
|
82
|
-
this.#openFd(activePath);
|
|
83
|
-
}
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
#openFd(filePath: string): void {
|
|
@@ -104,41 +106,84 @@ export class RotatingFileSink {
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Append one already-formatted log record. A batching sink buffers it unless
|
|
111
|
+
* `flushNow` is set or the buffer reached its size threshold; either way the
|
|
112
|
+
* whole buffer, this record included, is then written in order.
|
|
113
|
+
*/
|
|
114
|
+
write(line: string, flushNow = false): void {
|
|
109
115
|
if (this.#closed) return;
|
|
110
|
-
|
|
111
|
-
const now = new Date();
|
|
112
|
-
this.#selectFile(localDay(now));
|
|
113
|
-
const activePath = this.#activePath;
|
|
114
|
-
if (!activePath) return;
|
|
115
|
-
// Rotation moved the active path: close the old descriptor BEFORE
|
|
116
|
-
// registering the new path, because registration prunes beyond
|
|
117
|
-
// maxFiles — on Windows the pruned predecessor cannot be deleted
|
|
118
|
-
// while still open, which would leak it (audit entry removed, file
|
|
119
|
-
// left on disk, retention unbounded).
|
|
120
|
-
if (activePath !== prevPath) this.#closeFd();
|
|
121
|
-
this.#registerFile(activePath, now.getTime());
|
|
122
|
-
if (this.#fd === undefined) this.#openFd(activePath);
|
|
116
|
+
this.#selectFile(localDay(new Date()));
|
|
123
117
|
const record = `${line}${os.EOL}`;
|
|
124
|
-
const
|
|
118
|
+
const bytes = Buffer.byteLength(record, "utf8");
|
|
119
|
+
this.#pending += record;
|
|
120
|
+
this.#pendingBytes += bytes;
|
|
121
|
+
this.#activeBytes += bytes;
|
|
122
|
+
const batch = this.#batch;
|
|
123
|
+
if (flushNow || !batch || this.#pendingBytes >= batch.maxBytes) {
|
|
124
|
+
this.flush();
|
|
125
|
+
} else if (this.#flushTimer === undefined) {
|
|
126
|
+
this.#flushTimer = setTimeout(this.#flushFromTimer, batch.intervalMs);
|
|
127
|
+
this.#flushTimer.unref();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Write every buffered record to the active file now. */
|
|
132
|
+
flush(): void {
|
|
133
|
+
if (this.#flushTimer !== undefined) {
|
|
134
|
+
clearTimeout(this.#flushTimer);
|
|
135
|
+
this.#flushTimer = undefined;
|
|
136
|
+
}
|
|
137
|
+
const activePath = this.#activePath;
|
|
138
|
+
if (this.#pendingBytes === 0 || !activePath) return;
|
|
139
|
+
const buf = Buffer.from(this.#pending, "utf8");
|
|
140
|
+
// Drop the batch even if the write below fails, so a broken file cannot
|
|
141
|
+
// make the buffer grow without bound.
|
|
142
|
+
this.#pending = "";
|
|
143
|
+
this.#pendingBytes = 0;
|
|
144
|
+
if (this.#fd === undefined) {
|
|
145
|
+
this.#registerFile(activePath);
|
|
146
|
+
this.#openFd(activePath);
|
|
147
|
+
}
|
|
125
148
|
let off = 0;
|
|
126
149
|
while (off < buf.length) {
|
|
127
150
|
const written = fs.writeSync(this.#fd!, buf, off);
|
|
128
151
|
if (written <= 0) break;
|
|
129
152
|
off += written;
|
|
130
153
|
}
|
|
131
|
-
this.#activeBytes += buf.length;
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
|
|
156
|
+
#flushFromTimer = (): void => {
|
|
157
|
+
this.#flushTimer = undefined;
|
|
158
|
+
try {
|
|
159
|
+
this.flush();
|
|
160
|
+
} catch {
|
|
161
|
+
// A background flush has no caller to report to; logging never throws.
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Write buffered records, then stop accepting records. */
|
|
135
166
|
close(): void {
|
|
167
|
+
if (this.#closed) return;
|
|
168
|
+
try {
|
|
169
|
+
this.flush();
|
|
170
|
+
} catch {
|
|
171
|
+
// Best-effort: a failed final write must not keep the descriptor open.
|
|
172
|
+
}
|
|
136
173
|
this.#closed = true;
|
|
137
174
|
this.#closeFd();
|
|
138
175
|
}
|
|
139
176
|
|
|
140
177
|
#selectFile(day: string): void {
|
|
141
|
-
|
|
178
|
+
const dayChanged = day !== this.#activeDay;
|
|
179
|
+
if (!dayChanged && this.#activeBytes <= this.#maxBytes) return;
|
|
180
|
+
// The buffered records belong to the current file: write them there, then
|
|
181
|
+
// close its descriptor BEFORE the next file is registered, because
|
|
182
|
+
// registration prunes beyond maxFiles — on Windows the pruned
|
|
183
|
+
// predecessor cannot be deleted while still open, which would leak it.
|
|
184
|
+
this.flush();
|
|
185
|
+
this.#closeFd();
|
|
186
|
+
if (dayChanged) {
|
|
142
187
|
this.#activeDay = day;
|
|
143
188
|
this.#activeIndex = 0;
|
|
144
189
|
this.#setActivePath(day, 0);
|
|
@@ -162,38 +207,17 @@ export class RotatingFileSink {
|
|
|
162
207
|
}
|
|
163
208
|
}
|
|
164
209
|
|
|
165
|
-
#registerFile(filePath: string
|
|
166
|
-
if (this.#files.
|
|
167
|
-
|
|
168
|
-
this.#files.push({ date, name: filePath, hash });
|
|
210
|
+
#registerFile(filePath: string): void {
|
|
211
|
+
if (this.#files.includes(filePath)) return;
|
|
212
|
+
this.#files.push(filePath);
|
|
169
213
|
while (this.#files.length > this.#maxFiles) {
|
|
170
214
|
const removed = this.#files.shift();
|
|
171
215
|
if (!removed) break;
|
|
172
216
|
try {
|
|
173
|
-
fs.rmSync(removed
|
|
217
|
+
fs.rmSync(removed, { force: true });
|
|
174
218
|
} catch {
|
|
175
219
|
// Retention is best-effort; the current record must still be written.
|
|
176
220
|
}
|
|
177
221
|
}
|
|
178
|
-
this.#writeAudit();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
#readAudit(): AuditEntry[] {
|
|
182
|
-
try {
|
|
183
|
-
const parsed = JSON.parse(fs.readFileSync(this.#auditFile, "utf8")) as { files?: unknown };
|
|
184
|
-
return Array.isArray(parsed.files) ? parsed.files.filter(isAuditEntry) : [];
|
|
185
|
-
} catch {
|
|
186
|
-
return [];
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
#writeAudit(): void {
|
|
191
|
-
const state: AuditState = {
|
|
192
|
-
keep: { days: false, amount: this.#maxFiles },
|
|
193
|
-
auditLog: this.#auditFile,
|
|
194
|
-
files: this.#files,
|
|
195
|
-
hashType: "sha256",
|
|
196
|
-
};
|
|
197
|
-
fs.writeFileSync(this.#auditFile, JSON.stringify(state, undefined, 4), "utf8");
|
|
198
222
|
}
|
|
199
223
|
}
|
package/src/logger.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* transport so a process supervisor (pm2, journald, k8s) captures the logs.
|
|
8
8
|
*
|
|
9
9
|
* Each entry includes `process.pid` so concurrent omp instances stay
|
|
10
|
-
* traceable.
|
|
10
|
+
* traceable. The file is created on the first record written. Records are
|
|
11
|
+
* batched — one write per second or per 64 KiB — while `warn`/`error` records
|
|
12
|
+
* are written at once together with everything buffered before them; exit,
|
|
13
|
+
* fatal, and signal paths flush the rest. `OMP_LOG_LEVEL` optionally limits
|
|
14
|
+
* the levels written to the file (default: all).
|
|
11
15
|
*/
|
|
12
16
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
13
17
|
import * as fs from "node:fs";
|
|
@@ -55,9 +59,13 @@ function emitToSinks(level: LogLevel, message: string, context: Record<string, u
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const PROCESS_LOG_PATTERN = /^omp\.(\d{4}-\d{2}-\d{2})\.(\d+)\.log(?:\.(\d+))?$/;
|
|
62
|
+
/** Per-process audit files written by earlier releases; current sinks track rotations in memory. */
|
|
58
63
|
const PROCESS_AUDIT_PATTERN = /^\.omp\.(\d+)-audit\.json$/;
|
|
64
|
+
/** Shared daily logs (plain, size-rolled, or gzipped) written by the winston-era logger. */
|
|
65
|
+
const LEGACY_LOG_PATTERN = /^omp\.(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?(?:\.gz)?$/;
|
|
66
|
+
/** Hash-named audit files written by winston-daily-rotate-file. */
|
|
67
|
+
const LEGACY_AUDIT_PATTERN = /^\.[0-9a-f]{8,}-audit\.json$/;
|
|
59
68
|
const RETAINED_STALE_LOGS_PER_PROCESS_DAY = 1;
|
|
60
|
-
const RETAINED_STALE_AUDIT_FILES = 0;
|
|
61
69
|
const RETAINED_STALE_LOG_DAYS = 5;
|
|
62
70
|
|
|
63
71
|
function processIsRunning(pid: number): boolean {
|
|
@@ -71,9 +79,11 @@ function processIsRunning(pid: number): boolean {
|
|
|
71
79
|
|
|
72
80
|
/**
|
|
73
81
|
* Retain one newest completed-process log per process/day within the current
|
|
74
|
-
* and previous four local calendar days,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
82
|
+
* and previous four local calendar days, remove completed-process audit files
|
|
83
|
+
* left by earlier releases, and age out legacy shared daily logs and their
|
|
84
|
+
* hash-named audits past the same window. Live PID namespaces are never
|
|
85
|
+
* touched. The calendar-day boundary preserves daily diagnostic coverage while
|
|
86
|
+
* bounding completed-process storage and scans.
|
|
77
87
|
*/
|
|
78
88
|
function pruneStaleProcessLogs(dir: string): void {
|
|
79
89
|
let entries: fs.Dirent[];
|
|
@@ -87,33 +97,38 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
87
97
|
const cutoff = new Date(current);
|
|
88
98
|
cutoff.setDate(cutoff.getDate() - (RETAINED_STALE_LOG_DAYS - 1));
|
|
89
99
|
const cutoffDate = localDay(cutoff);
|
|
100
|
+
cutoff.setHours(0, 0, 0, 0);
|
|
101
|
+
const cutoffMs = cutoff.getTime();
|
|
90
102
|
|
|
91
103
|
const staleLogsByProcessDay = new Map<string, Array<{ path: string; rollover: number }>>();
|
|
92
104
|
for (const entry of entries) {
|
|
93
105
|
if (!entry.isFile()) continue;
|
|
106
|
+
const entryPath = path.join(dir, entry.name);
|
|
107
|
+
const legacyMatch = LEGACY_LOG_PATTERN.exec(entry.name);
|
|
108
|
+
if (legacyMatch?.[1]) {
|
|
109
|
+
if (legacyMatch[1] < cutoffDate) removeBestEffort(entryPath);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (LEGACY_AUDIT_PATTERN.test(entry.name)) {
|
|
113
|
+
try {
|
|
114
|
+
if (fs.statSync(entryPath).mtimeMs < cutoffMs) removeBestEffort(entryPath);
|
|
115
|
+
} catch {
|
|
116
|
+
// Another process may have pruned the same legacy audit.
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
94
120
|
const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
|
|
95
121
|
const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
|
|
96
122
|
const pidText = logMatch?.[2] ?? auditMatch?.[1];
|
|
97
123
|
if (!pidText || processIsRunning(Number(pidText))) continue;
|
|
98
|
-
const entryPath = path.join(dir, entry.name);
|
|
99
124
|
|
|
100
125
|
if (auditMatch) {
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
fs.rmSync(entryPath, { force: true });
|
|
104
|
-
} catch {
|
|
105
|
-
// Retention is best-effort; logging must still initialize.
|
|
106
|
-
}
|
|
107
|
-
}
|
|
126
|
+
removeBestEffort(entryPath);
|
|
108
127
|
continue;
|
|
109
128
|
}
|
|
110
129
|
if (!logMatch?.[1]) continue;
|
|
111
130
|
if (logMatch[1] < cutoffDate || logMatch[1] > currentDate) {
|
|
112
|
-
|
|
113
|
-
fs.rmSync(entryPath, { force: true });
|
|
114
|
-
} catch {
|
|
115
|
-
// Another process may have pruned the same stale namespace.
|
|
116
|
-
}
|
|
131
|
+
removeBestEffort(entryPath);
|
|
117
132
|
continue;
|
|
118
133
|
}
|
|
119
134
|
|
|
@@ -139,13 +154,15 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
139
154
|
ranked.sort(
|
|
140
155
|
(a, b) => b.mtimeMs - a.mtimeMs || b.rollover - a.rollover || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
|
|
141
156
|
);
|
|
142
|
-
for (const stale of ranked.slice(RETAINED_STALE_LOGS_PER_PROCESS_DAY))
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
157
|
+
for (const stale of ranked.slice(RETAINED_STALE_LOGS_PER_PROCESS_DAY)) removeBestEffort(stale.path);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function removeBestEffort(filePath: string): void {
|
|
162
|
+
try {
|
|
163
|
+
fs.rmSync(filePath, { force: true });
|
|
164
|
+
} catch {
|
|
165
|
+
// Retention is best-effort; another process may have pruned the same file.
|
|
149
166
|
}
|
|
150
167
|
}
|
|
151
168
|
|
|
@@ -244,22 +261,72 @@ function formatLogInfo(info: NormalizedLogInfo): string {
|
|
|
244
261
|
return JSON.stringify(entry, jsonReplacer) as string;
|
|
245
262
|
}
|
|
246
263
|
|
|
264
|
+
/** Longest a buffered file record waits before it is written. */
|
|
265
|
+
const FILE_FLUSH_INTERVAL_MS = 1_000;
|
|
266
|
+
/** Buffered bytes at which the file batch is written immediately. */
|
|
267
|
+
const FILE_FLUSH_BYTES = 64 * 1024;
|
|
268
|
+
|
|
269
|
+
let exitFlushInstalled = false;
|
|
270
|
+
/** Set once the process is exiting: every later record is written immediately. */
|
|
271
|
+
let exiting = false;
|
|
272
|
+
|
|
273
|
+
function flushOnExit(): void {
|
|
274
|
+
exiting = true;
|
|
275
|
+
flush();
|
|
276
|
+
}
|
|
277
|
+
|
|
247
278
|
/** Build a rotating file sink with process-local rotation and shared retention. */
|
|
248
279
|
function makeFileTransport(dir?: string): RotatingFileSink {
|
|
249
280
|
const logsDir = ensureDir(dir ?? getLogsDir());
|
|
250
281
|
schedulePruneStaleProcessLogs(logsDir);
|
|
282
|
+
if (!exitFlushInstalled) {
|
|
283
|
+
exitFlushInstalled = true;
|
|
284
|
+
// Natural exit and process.exit(); signal and fatal exits that bypass the
|
|
285
|
+
// `exit` event flush through postmortem's exitProcess().
|
|
286
|
+
process.on("exit", flushOnExit);
|
|
287
|
+
}
|
|
288
|
+
// The sink opens (and creates) its file on the first batch written.
|
|
251
289
|
return new RotatingFileSink({
|
|
252
290
|
directory: logsDir,
|
|
253
291
|
filenamePrefix: "omp",
|
|
254
292
|
filenameSuffix: String(process.pid),
|
|
255
293
|
maxBytes: 10 * 1024 * 1024,
|
|
256
294
|
maxFiles: 5,
|
|
257
|
-
|
|
295
|
+
batch: { intervalMs: FILE_FLUSH_INTERVAL_MS, maxBytes: FILE_FLUSH_BYTES },
|
|
258
296
|
// Keep the stderr guard's fd 2 on the file this sink is writing.
|
|
259
297
|
onRotate: setStderrRedirectTarget,
|
|
260
298
|
});
|
|
261
299
|
}
|
|
262
300
|
|
|
301
|
+
const LOG_LEVEL_RANK: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
302
|
+
/** `warn` and `error` records are written at once, with everything buffered before them. */
|
|
303
|
+
const IMMEDIATE_FLUSH_RANK = LOG_LEVEL_RANK.warn;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Rank of the most verbose level the file transport persists: `OMP_LOG_LEVEL`
|
|
307
|
+
* (error|warn|info|debug, case-insensitive), default `debug` (everything).
|
|
308
|
+
*/
|
|
309
|
+
function resolveFileLevelRank(): number {
|
|
310
|
+
const requested = process.env.OMP_LOG_LEVEL?.trim().toLowerCase();
|
|
311
|
+
return requested && Object.hasOwn(LOG_LEVEL_RANK, requested)
|
|
312
|
+
? LOG_LEVEL_RANK[requested as LogLevel]
|
|
313
|
+
: LOG_LEVEL_RANK.debug;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Write buffered file records to disk now. The file transport batches records
|
|
318
|
+
* (one write per second or per 64 KiB) and writes `warn`/`error` records
|
|
319
|
+
* immediately; process exit flushes on its own. Call this before handing the
|
|
320
|
+
* log file to something that reads it in-process. Never throws.
|
|
321
|
+
*/
|
|
322
|
+
export function flush(): void {
|
|
323
|
+
try {
|
|
324
|
+
activeTransports?.file?.flush();
|
|
325
|
+
} catch {
|
|
326
|
+
// Flushing diagnostics must never mask the caller's own work or failure.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
263
330
|
/**
|
|
264
331
|
* Desired transport configuration, applied when local logging is initialized.
|
|
265
332
|
* Default: file ON (TUI-safe), console OFF.
|
|
@@ -269,6 +336,8 @@ let transportOpts: { console?: boolean; file?: boolean | string } = { file: true
|
|
|
269
336
|
interface LocalTransports {
|
|
270
337
|
readonly file: RotatingFileSink | undefined;
|
|
271
338
|
readonly console: boolean;
|
|
339
|
+
/** Rank of the most verbose level written to {@link file}. */
|
|
340
|
+
readonly fileLevelRank: number;
|
|
272
341
|
}
|
|
273
342
|
|
|
274
343
|
/** Local transports, constructed lazily on first log emission. */
|
|
@@ -278,6 +347,7 @@ function buildTransports(opts: { console?: boolean; file?: boolean | string }):
|
|
|
278
347
|
return {
|
|
279
348
|
file: opts.file ? makeFileTransport(typeof opts.file === "string" ? opts.file : undefined) : undefined,
|
|
280
349
|
console: opts.console === true,
|
|
350
|
+
fileLevelRank: resolveFileLevelRank(),
|
|
281
351
|
};
|
|
282
352
|
}
|
|
283
353
|
|
|
@@ -288,10 +358,12 @@ function getLocalTransports(): LocalTransports {
|
|
|
288
358
|
|
|
289
359
|
function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
|
|
290
360
|
const transports = getLocalTransports();
|
|
291
|
-
|
|
361
|
+
const rank = LOG_LEVEL_RANK[level];
|
|
362
|
+
const file = rank <= transports.fileLevelRank ? transports.file : undefined;
|
|
363
|
+
if (!file && !transports.console) return;
|
|
292
364
|
const info = normalizeLogInfo(level, message, context);
|
|
293
365
|
const line = formatLogInfo(info);
|
|
294
|
-
|
|
366
|
+
file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting);
|
|
295
367
|
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
296
368
|
}
|
|
297
369
|
|
|
@@ -299,12 +371,14 @@ function emitLocally(level: LogLevel, message: string, context: Record<string, u
|
|
|
299
371
|
* Replace the active log transports. Pass `console: true, file: false` for
|
|
300
372
|
* long-running services (the auth broker, etc.) that want their structured
|
|
301
373
|
* logs piped into a process supervisor instead of the rotating file.
|
|
374
|
+
* The previous file transport writes its buffered records before closing, and
|
|
375
|
+
* the file level (`OMP_LOG_LEVEL`) is re-read on each reconfiguration.
|
|
302
376
|
*/
|
|
303
377
|
export function setTransports(opts: { console?: boolean; file?: boolean | string }): void {
|
|
304
378
|
transportOpts = opts;
|
|
305
379
|
if (!activeTransports) return; // applied lazily when local logging is first initialized
|
|
306
380
|
const previousTransports = activeTransports;
|
|
307
|
-
activeTransports = { file: undefined, console: false };
|
|
381
|
+
activeTransports = { file: undefined, console: false, fileLevelRank: previousTransports.fileLevelRank };
|
|
308
382
|
previousTransports.file?.close();
|
|
309
383
|
activeTransports = buildTransports(opts);
|
|
310
384
|
}
|