@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.
@@ -1,21 +1,16 @@
1
- /** Behavior-compatible reimplementation of winston-daily-rotate-file's used surface. */
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
- interface AuditEntry {
9
- readonly date: number;
10
- readonly name: string;
11
- readonly hash: string;
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 — construction, local-day rotation, and size rotation alike.
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
- function isAuditEntry(value: unknown): value is AuditEntry {
41
- if (value === null || typeof value !== "object") return false;
42
- const entry = value as Record<string, unknown>;
43
- return typeof entry.date === "number" && typeof entry.name === "string" && typeof entry.hash === "string";
44
- }
45
-
46
- /** Synchronous append sink with local-day and size rotation plus bounded retention. */
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
- #files: AuditEntry[];
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: the old code did open+write+close per line
62
- // (appendFileSync) plus a throwaway open in the constructor. A single fd
63
- // per active file removes two syscalls per log line; it is reopened on
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
- /** Append one already-formatted log record. */
108
- write(line: string): void {
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
- const prevPath = this.#activePath;
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 buf = Buffer.from(record, "utf8");
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
- /** Stop accepting records. Synchronous writes require no drain phase. */
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
- if (day !== this.#activeDay) {
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, date: number): void {
166
- if (this.#files.some(file => file.name === filePath)) return;
167
- const hash = Bun.SHA256.hash(`${filePath}LOG_FILE${date}`, "hex");
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.name, { force: true });
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, and remove one-use audit files. Live
75
- * PID namespaces are never touched. The calendar-day boundary preserves daily
76
- * diagnostic coverage while bounding completed-process storage and scans.
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
- if (RETAINED_STALE_AUDIT_FILES === 0) {
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
- try {
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
- try {
144
- fs.rmSync(stale.path, { force: true });
145
- } catch {
146
- // Another process may have pruned the same stale namespace.
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
- auditFile: path.join(logsDir, `.omp.${process.pid}-audit.json`),
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
- if (!transports.file && !transports.console) return;
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
- if (transports.file) transports.file.write(line);
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
  }