@oh-my-pi/pi-utils 18.4.6 → 18.4.9
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 +11 -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/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/postmortem.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.9] - 2026-10-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `tryAcquireFileLock`, a non-blocking file-lock helper that returns `null` when the lock is already held.
|
|
10
|
+
- Added an `unref` option to `AsyncDrain`, allowing applications to use long batch windows without keeping the process alive.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Improved logging efficiency and configurability by batching routine file writes, flushing urgent records promptly, adding on-demand `logger.flush()` support, and allowing file log levels to be limited with `OMP_LOG_LEVEL`. Log files are created only when needed, and obsolete log and audit files are cleaned up automatically.
|
|
15
|
+
|
|
5
16
|
## [18.4.4] - 2026-09-29
|
|
6
17
|
|
|
7
18
|
### Added
|
package/dist/types/async.d.ts
CHANGED
|
@@ -28,11 +28,16 @@ export declare function withTimeout<T>(promise: Promise<T>, ms: number, timeout:
|
|
|
28
28
|
* timer (`delayMs`, or a microtask at 0), and every push before it fires joins
|
|
29
29
|
* the same batch and shares the same promise. Used to keep hot paths off
|
|
30
30
|
* synchronous storage (prompt history, model perf).
|
|
31
|
+
*
|
|
32
|
+
* `unref` lets a long batch window not hold the process open; the owner must
|
|
33
|
+
* then {@link flush} on shutdown or the pending batch is lost.
|
|
31
34
|
*/
|
|
32
35
|
export declare class AsyncDrain<T> {
|
|
33
36
|
#private;
|
|
34
37
|
readonly delayMs: number;
|
|
35
|
-
constructor(delayMs?: number
|
|
38
|
+
constructor(delayMs?: number, options?: {
|
|
39
|
+
unref?: boolean;
|
|
40
|
+
});
|
|
36
41
|
/** Queue `value`; `hnd` receives the whole batch when the window closes. */
|
|
37
42
|
push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void>;
|
|
38
43
|
/** Runs the pending batch handler immediately and returns its completion promise. */
|
|
@@ -14,6 +14,8 @@ export interface FileLockHandle {
|
|
|
14
14
|
}
|
|
15
15
|
declare function getLockPath(filePath: string): string;
|
|
16
16
|
declare function tryAcquireLock(lockPath: string): NativeFileLock | null;
|
|
17
|
+
/** Acquire an exclusive lease without waiting; `null` while another holder owns it. */
|
|
18
|
+
export declare function tryAcquireFileLock(filePath: string): FileLockHandle | null;
|
|
17
19
|
/** Acquire an exclusive lease; callers must release it when their operation ends. */
|
|
18
20
|
export declare function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<FileLockHandle>;
|
|
19
21
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
/** Write batching for {@link RotatingFileSink}. */
|
|
2
|
+
export interface RotatingFileBatchOptions {
|
|
3
|
+
/** Longest a buffered record waits before an unref'd timer writes it. */
|
|
4
|
+
readonly intervalMs: number;
|
|
5
|
+
/** Buffered UTF-8 bytes at which the batch is written immediately. */
|
|
6
|
+
readonly maxBytes: number;
|
|
7
|
+
}
|
|
1
8
|
/** Configuration for a process-local rotating file sink. */
|
|
2
9
|
export interface RotatingFileOptions {
|
|
3
10
|
readonly directory: string;
|
|
4
11
|
readonly filenamePrefix: string;
|
|
5
12
|
readonly filenameSuffix: string;
|
|
6
|
-
readonly auditFile: string;
|
|
7
13
|
readonly maxBytes: number;
|
|
8
14
|
readonly maxFiles: number;
|
|
15
|
+
/**
|
|
16
|
+
* Buffer records and write them together instead of one write per record.
|
|
17
|
+
* Without it every record is written as it arrives.
|
|
18
|
+
*/
|
|
19
|
+
readonly batch?: RotatingFileBatchOptions;
|
|
9
20
|
/**
|
|
10
21
|
* Called with the new active file path whenever the sink opens a different
|
|
11
|
-
* file —
|
|
22
|
+
* file — first write, local-day rotation, and size rotation alike.
|
|
12
23
|
*
|
|
13
24
|
* Consumers that hold their own descriptor on the active log (the macOS
|
|
14
25
|
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
@@ -16,12 +27,31 @@ export interface RotatingFileOptions {
|
|
|
16
27
|
*/
|
|
17
28
|
readonly onRotate?: (filePath: string) => void;
|
|
18
29
|
}
|
|
19
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Synchronous append sink with local-day and size rotation plus bounded retention.
|
|
32
|
+
*
|
|
33
|
+
* Construction performs no filesystem I/O: the first file is created when the
|
|
34
|
+
* first record is written out, so a process that never logs leaves nothing on
|
|
35
|
+
* disk. Every file name embeds the owning process's suffix (its PID), so the
|
|
36
|
+
* set of files this sink created is tracked in memory; files left by earlier
|
|
37
|
+
* processes are pruned by name pattern in the logger, not by this sink.
|
|
38
|
+
*
|
|
39
|
+
* With {@link RotatingFileOptions.batch}, records are buffered and written in
|
|
40
|
+
* one `write` per batch. Buffered bytes already count toward the active file's
|
|
41
|
+
* size, and the buffer is written to its file before any rotation, so file
|
|
42
|
+
* selection matches an unbatched sink record for record.
|
|
43
|
+
*/
|
|
20
44
|
export declare class RotatingFileSink {
|
|
21
45
|
#private;
|
|
22
46
|
constructor(options: RotatingFileOptions);
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Append one already-formatted log record. A batching sink buffers it unless
|
|
49
|
+
* `flushNow` is set or the buffer reached its size threshold; either way the
|
|
50
|
+
* whole buffer, this record included, is then written in order.
|
|
51
|
+
*/
|
|
52
|
+
write(line: string, flushNow?: boolean): void;
|
|
53
|
+
/** Write every buffered record to the active file now. */
|
|
54
|
+
flush(): void;
|
|
55
|
+
/** Write buffered records, then stop accepting records. */
|
|
26
56
|
close(): void;
|
|
27
57
|
}
|
package/dist/types/logger.d.ts
CHANGED
|
@@ -11,10 +11,19 @@ export interface LogEvent {
|
|
|
11
11
|
export type LogSink = (event: LogEvent) => void;
|
|
12
12
|
/** Register an out-of-band log sink and return a disposer. */
|
|
13
13
|
export declare function registerLogSink(sink: LogSink): () => void;
|
|
14
|
+
/**
|
|
15
|
+
* Write buffered file records to disk now. The file transport batches records
|
|
16
|
+
* (one write per second or per 64 KiB) and writes `warn`/`error` records
|
|
17
|
+
* immediately; process exit flushes on its own. Call this before handing the
|
|
18
|
+
* log file to something that reads it in-process. Never throws.
|
|
19
|
+
*/
|
|
20
|
+
export declare function flush(): void;
|
|
14
21
|
/**
|
|
15
22
|
* Replace the active log transports. Pass `console: true, file: false` for
|
|
16
23
|
* long-running services (the auth broker, etc.) that want their structured
|
|
17
24
|
* logs piped into a process supervisor instead of the rotating file.
|
|
25
|
+
* The previous file transport writes its buffered records before closing, and
|
|
26
|
+
* the file level (`OMP_LOG_LEVEL`) is re-read on each reconfiguration.
|
|
18
27
|
*/
|
|
19
28
|
export declare function setTransports(opts: {
|
|
20
29
|
console?: boolean;
|
|
@@ -46,6 +46,10 @@ export declare const NATIVE_PROCESS_EXIT: unique symbol;
|
|
|
46
46
|
* guard and loops the rejection storm (#11789). After restoring, `reallyExit`
|
|
47
47
|
* (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
|
|
48
48
|
* are fallbacks so a poisoned or absent chain can never leave the process alive.
|
|
49
|
+
*
|
|
50
|
+
* Buffered log records are written first: `reallyExit` skips the `exit` event
|
|
51
|
+
* the logger otherwise flushes on, which would drop the last batch on every
|
|
52
|
+
* signal and fatal exit.
|
|
49
53
|
*/
|
|
50
54
|
export declare function exitProcess(code: number): never;
|
|
51
55
|
/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.4.
|
|
3
|
+
"version": "18.4.9",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.4.9"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.3.14"
|
package/src/async.ts
CHANGED
|
@@ -91,13 +91,22 @@ export function withTimeout<T>(
|
|
|
91
91
|
* timer (`delayMs`, or a microtask at 0), and every push before it fires joins
|
|
92
92
|
* the same batch and shares the same promise. Used to keep hot paths off
|
|
93
93
|
* synchronous storage (prompt history, model perf).
|
|
94
|
+
*
|
|
95
|
+
* `unref` lets a long batch window not hold the process open; the owner must
|
|
96
|
+
* then {@link flush} on shutdown or the pending batch is lost.
|
|
94
97
|
*/
|
|
95
98
|
export class AsyncDrain<T> {
|
|
96
99
|
#queue?: T[];
|
|
97
100
|
#promise = Promise.resolve();
|
|
98
101
|
#flush?: () => void;
|
|
102
|
+
readonly #unref: boolean;
|
|
99
103
|
|
|
100
|
-
constructor(
|
|
104
|
+
constructor(
|
|
105
|
+
readonly delayMs: number = 0,
|
|
106
|
+
options?: { unref?: boolean },
|
|
107
|
+
) {
|
|
108
|
+
this.#unref = options?.unref === true;
|
|
109
|
+
}
|
|
101
110
|
|
|
102
111
|
/** Queue `value`; `hnd` receives the whole batch when the window closes. */
|
|
103
112
|
push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void> {
|
|
@@ -119,6 +128,7 @@ export class AsyncDrain<T> {
|
|
|
119
128
|
};
|
|
120
129
|
if (this.delayMs > 0) {
|
|
121
130
|
const timer = setTimeout(exec, this.delayMs);
|
|
131
|
+
if (this.#unref) timer.unref();
|
|
122
132
|
this.#flush = () => {
|
|
123
133
|
clearTimeout(timer);
|
|
124
134
|
exec();
|
package/src/file-lock.ts
CHANGED
|
@@ -34,7 +34,14 @@ function getLockPath(filePath: string): string {
|
|
|
34
34
|
|
|
35
35
|
function tryAcquireLock(lockPath: string): NativeFileLock | null {
|
|
36
36
|
const lock = NativeFileLock.tryAcquire(lockPath);
|
|
37
|
-
|
|
37
|
+
if (lock.acquired) return lock;
|
|
38
|
+
lock.release();
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Acquire an exclusive lease without waiting; `null` while another holder owns it. */
|
|
43
|
+
export function tryAcquireFileLock(filePath: string): FileLockHandle | null {
|
|
44
|
+
return tryAcquireLock(getLockPath(filePath));
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/** Acquire an exclusive lease; callers must release it when their operation ends. */
|
|
@@ -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
|
}
|
package/src/postmortem.ts
CHANGED
|
@@ -97,8 +97,13 @@ function nativeHardExit(fn: HardExitFn | undefined): HardExitFn | undefined {
|
|
|
97
97
|
* guard and loops the rejection storm (#11789). After restoring, `reallyExit`
|
|
98
98
|
* (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
|
|
99
99
|
* are fallbacks so a poisoned or absent chain can never leave the process alive.
|
|
100
|
+
*
|
|
101
|
+
* Buffered log records are written first: `reallyExit` skips the `exit` event
|
|
102
|
+
* the logger otherwise flushes on, which would drop the last batch on every
|
|
103
|
+
* signal and fatal exit.
|
|
100
104
|
*/
|
|
101
105
|
export function exitProcess(code: number): never {
|
|
106
|
+
logger.flush();
|
|
102
107
|
const reallyExit = nativeHardExit(typeof process.reallyExit === "function" ? process.reallyExit : undefined);
|
|
103
108
|
const exit = nativeHardExit(process.exit as HardExitFn);
|
|
104
109
|
if (reallyExit) process.reallyExit = reallyExit as typeof process.reallyExit;
|