@milaboratories/pl-crash-recorder 0.3.0
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/README.md +119 -0
- package/dist/data_summary.js +100 -0
- package/dist/data_summary.js.map +1 -0
- package/dist/digest.js +26 -0
- package/dist/digest.js.map +1 -0
- package/dist/events.d.ts +149 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +13 -0
- package/dist/events.js.map +1 -0
- package/dist/host_sampler.d.ts +35 -0
- package/dist/host_sampler.d.ts.map +1 -0
- package/dist/host_sampler.js +54 -0
- package/dist/host_sampler.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/instrument.d.ts +79 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +305 -0
- package/dist/instrument.js.map +1 -0
- package/dist/machine_memory.js +68 -0
- package/dist/machine_memory.js.map +1 -0
- package/dist/recorder.d.ts +70 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +278 -0
- package/dist/recorder.js.map +1 -0
- package/dist/redact.js +141 -0
- package/dist/redact.js.map +1 -0
- package/dist/sampler.d.ts +8 -0
- package/dist/sampler.d.ts.map +1 -0
- package/dist/sampler.js +33 -0
- package/dist/sampler.js.map +1 -0
- package/dist/sampler_thread.d.ts +1 -0
- package/dist/sampler_thread.js +53 -0
- package/dist/sampler_thread.js.map +1 -0
- package/dist/session.d.ts +40 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/supervisor.d.ts +37 -0
- package/dist/supervisor.d.ts.map +1 -0
- package/dist/supervisor.js +138 -0
- package/dist/supervisor.js.map +1 -0
- package/package.json +43 -0
- package/src/data_summary.ts +163 -0
- package/src/digest.ts +36 -0
- package/src/events.ts +166 -0
- package/src/host_sampler.ts +83 -0
- package/src/index.ts +51 -0
- package/src/instrument.ts +480 -0
- package/src/machine_memory.ts +70 -0
- package/src/recorder.test.ts +334 -0
- package/src/recorder.ts +435 -0
- package/src/redact.test.ts +155 -0
- package/src/redact.ts +213 -0
- package/src/sampler.ts +40 -0
- package/src/sampler_thread.ts +60 -0
- package/src/session.ts +71 -0
- package/src/supervisor.ts +183 -0
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { MEM_BASELINE_RECORD, SESSION_END_RECORD, SESSION_FILE_PREFIX, SESSION_RECORD } from "./events.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import v8 from "node:v8";
|
|
6
|
+
//#region src/recorder.ts
|
|
7
|
+
/**
|
|
8
|
+
* Opens a crash log for this process and writes the session header.
|
|
9
|
+
*
|
|
10
|
+
* Records are appended with a synchronous write rather than through a stream:
|
|
11
|
+
* the process being recorded dies without warning — V8 fatal out-of-memory, a
|
|
12
|
+
* failed native allocation, the OS out-of-memory killer — so no exit hook, no
|
|
13
|
+
* flush and no `finally` block runs. Anything still sitting in a userspace
|
|
14
|
+
* buffer is exactly the part that would have explained the death.
|
|
15
|
+
*/
|
|
16
|
+
function openRecorder(options) {
|
|
17
|
+
const { dir, role = "middle-layer", meta = {}, maxFileBytes = 32 * 1024 * 1024, sessionId = newSessionId() } = options;
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
const file = path.join(dir, `${SESSION_FILE_PREFIX}-${sessionId}.ndjson`);
|
|
20
|
+
const state = {
|
|
21
|
+
fd: fs.openSync(file, "a"),
|
|
22
|
+
bytes: 0,
|
|
23
|
+
preambleBytes: 0,
|
|
24
|
+
seq: 0,
|
|
25
|
+
closed: false,
|
|
26
|
+
header: void 0,
|
|
27
|
+
baselineMem: void 0,
|
|
28
|
+
openBegins: /* @__PURE__ */ new Map(),
|
|
29
|
+
sticky: /* @__PURE__ */ new Map()
|
|
30
|
+
};
|
|
31
|
+
const memorySnapshot = () => {
|
|
32
|
+
const usage = process.memoryUsage();
|
|
33
|
+
return {
|
|
34
|
+
rss: usage.rss,
|
|
35
|
+
heapUsed: usage.heapUsed,
|
|
36
|
+
heapTotal: usage.heapTotal,
|
|
37
|
+
external: usage.external,
|
|
38
|
+
arrayBuffers: usage.arrayBuffers,
|
|
39
|
+
heapLimit: v8.getHeapStatistics().heap_size_limit
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const event = (type, payload = {}) => {
|
|
43
|
+
if (state.closed) return -1;
|
|
44
|
+
const seq = ++state.seq;
|
|
45
|
+
const record = {
|
|
46
|
+
seq,
|
|
47
|
+
t: monotonic(),
|
|
48
|
+
wall: Date.now(),
|
|
49
|
+
type,
|
|
50
|
+
...payload
|
|
51
|
+
};
|
|
52
|
+
if (state.baselineMem === void 0 && record.mem) state.baselineMem = record.mem;
|
|
53
|
+
trackOpenOperation(state, record);
|
|
54
|
+
writeLine(state, file, maxFileBytes, record);
|
|
55
|
+
return seq;
|
|
56
|
+
};
|
|
57
|
+
const sticky = (key, type, payload) => {
|
|
58
|
+
if (state.sticky.has(key)) return;
|
|
59
|
+
const retained = state.sticky.size < MAX_STICKY_RECORDS;
|
|
60
|
+
const seq = event(type, retained ? payload : {
|
|
61
|
+
...payload,
|
|
62
|
+
notRetained: true
|
|
63
|
+
});
|
|
64
|
+
if (!retained) return;
|
|
65
|
+
const record = {
|
|
66
|
+
...payload,
|
|
67
|
+
seq,
|
|
68
|
+
type
|
|
69
|
+
};
|
|
70
|
+
state.sticky.set(key, record);
|
|
71
|
+
};
|
|
72
|
+
const recorder = {
|
|
73
|
+
sessionId,
|
|
74
|
+
file,
|
|
75
|
+
event,
|
|
76
|
+
sticky,
|
|
77
|
+
memorySnapshot,
|
|
78
|
+
close(reason = "normal") {
|
|
79
|
+
if (state.closed) return;
|
|
80
|
+
event(SESSION_END_RECORD, {
|
|
81
|
+
reason,
|
|
82
|
+
mem: memorySnapshot()
|
|
83
|
+
});
|
|
84
|
+
state.closed = true;
|
|
85
|
+
try {
|
|
86
|
+
fs.closeSync(state.fd);
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
state.header = {
|
|
91
|
+
role,
|
|
92
|
+
pid: process.pid,
|
|
93
|
+
meta,
|
|
94
|
+
env: describeEnvironment()
|
|
95
|
+
};
|
|
96
|
+
event(SESSION_RECORD, {
|
|
97
|
+
...state.header,
|
|
98
|
+
mem: memorySnapshot()
|
|
99
|
+
});
|
|
100
|
+
return recorder;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Periodic memory record written by the recorded thread itself.
|
|
104
|
+
*
|
|
105
|
+
* Doubles as a stall detector. This timer cannot fire while its thread is inside
|
|
106
|
+
* a long synchronous call, so a gap here that the independent sampler thread
|
|
107
|
+
* does not share means the thread was blocked — which is also why the heap
|
|
108
|
+
* reading nearest a synchronous blow-up is always stale.
|
|
109
|
+
*/
|
|
110
|
+
function startSelfSampler(recorder, intervalMs = 500) {
|
|
111
|
+
let last = Date.now();
|
|
112
|
+
const timer = setInterval(() => {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
recorder.event("mem-self", {
|
|
115
|
+
mem: recorder.memorySnapshot(),
|
|
116
|
+
stallMs: Math.max(0, now - last - intervalMs)
|
|
117
|
+
});
|
|
118
|
+
last = now;
|
|
119
|
+
}, intervalMs);
|
|
120
|
+
timer.unref();
|
|
121
|
+
return () => clearInterval(timer);
|
|
122
|
+
}
|
|
123
|
+
/** Crash logs in a directory, newest first, each flagged as crashed or clean. */
|
|
124
|
+
function listSessions(dir) {
|
|
125
|
+
let names;
|
|
126
|
+
try {
|
|
127
|
+
names = fs.readdirSync(dir);
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return names.filter((name) => name.startsWith(`session-`) && name.endsWith(".ndjson")).map((name) => {
|
|
132
|
+
const file = path.join(dir, name);
|
|
133
|
+
const stat = fs.statSync(file);
|
|
134
|
+
return {
|
|
135
|
+
file,
|
|
136
|
+
mtimeMs: stat.mtimeMs,
|
|
137
|
+
bytes: stat.size,
|
|
138
|
+
crashed: !hasSessionEnd(file)
|
|
139
|
+
};
|
|
140
|
+
}).sort((lhs, rhs) => rhs.mtimeMs - lhs.mtimeMs);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Mints a session id. A parent that supervises a worker calls this, hands the id
|
|
144
|
+
* to the worker, and keeps it for the crash marker, so both sides agree on the
|
|
145
|
+
* session by construction. The leading timestamp is what
|
|
146
|
+
* {@link sessionStartFromId} reads back.
|
|
147
|
+
*/
|
|
148
|
+
function newSessionId() {
|
|
149
|
+
return `${Date.now()}-${process.pid}-${randomTag()}`;
|
|
150
|
+
}
|
|
151
|
+
/** Session id embedded in a crash log's file name. */
|
|
152
|
+
function sessionIdFromFile(file) {
|
|
153
|
+
const match = path.basename(file).match(new RegExp(`^${SESSION_FILE_PREFIX}-(.+)\\.ndjson(\\.1)?$`));
|
|
154
|
+
return match ? match[1] : path.basename(file);
|
|
155
|
+
}
|
|
156
|
+
/** How many sticky records a session may keep, bounding the rewritten preamble. */
|
|
157
|
+
const MAX_STICKY_RECORDS = 64;
|
|
158
|
+
/** Cap on carried-forward begins, so a leak cannot make the preamble unbounded. */
|
|
159
|
+
const MAX_CARRIED_BEGINS = 256;
|
|
160
|
+
function writeLine(state, file, maxFileBytes, record) {
|
|
161
|
+
let line;
|
|
162
|
+
try {
|
|
163
|
+
line = `${JSON.stringify(record, bigintSafe)}\n`;
|
|
164
|
+
} catch {
|
|
165
|
+
line = `${JSON.stringify({
|
|
166
|
+
seq: record.seq,
|
|
167
|
+
type: "record-serialization-failed"
|
|
168
|
+
})}\n`;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
if (state.bytes - state.preambleBytes + line.length > maxFileBytes) {
|
|
172
|
+
rotate(state, file);
|
|
173
|
+
writePreamble(state);
|
|
174
|
+
}
|
|
175
|
+
fs.writeSync(state.fd, line);
|
|
176
|
+
state.bytes += line.length;
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
function rotate(state, file) {
|
|
180
|
+
fs.closeSync(state.fd);
|
|
181
|
+
try {
|
|
182
|
+
fs.renameSync(file, `${file}.1`);
|
|
183
|
+
} catch {}
|
|
184
|
+
state.fd = fs.openSync(file, "a");
|
|
185
|
+
state.bytes = 0;
|
|
186
|
+
state.preambleBytes = 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Rewrites into the new segment the three things a report cannot be produced
|
|
190
|
+
* without, so repeated rotation costs only completed operations.
|
|
191
|
+
*
|
|
192
|
+
* Losing an *open* begin record would remove that operation from pairing
|
|
193
|
+
* entirely, and the operation still running at the moment of death is the one
|
|
194
|
+
* the report exists to name. Losing the earliest memory reading would leave the
|
|
195
|
+
* heap series starting mid-session while the sampler's resident series still
|
|
196
|
+
* starts at zero, which biases the classifier toward blaming native memory.
|
|
197
|
+
*/
|
|
198
|
+
function writePreamble(state) {
|
|
199
|
+
if (state.header) emitPreambleRecord(state, {
|
|
200
|
+
seq: ++state.seq,
|
|
201
|
+
t: monotonic(),
|
|
202
|
+
wall: Date.now(),
|
|
203
|
+
type: SESSION_RECORD,
|
|
204
|
+
...state.header,
|
|
205
|
+
continuation: true
|
|
206
|
+
});
|
|
207
|
+
if (state.baselineMem) emitPreambleRecord(state, {
|
|
208
|
+
seq: ++state.seq,
|
|
209
|
+
t: monotonic(),
|
|
210
|
+
wall: Date.now(),
|
|
211
|
+
type: MEM_BASELINE_RECORD,
|
|
212
|
+
mem: state.baselineMem,
|
|
213
|
+
carriedForward: true
|
|
214
|
+
});
|
|
215
|
+
for (const record of state.sticky.values()) emitPreambleRecord(state, {
|
|
216
|
+
...record,
|
|
217
|
+
carriedForward: true
|
|
218
|
+
});
|
|
219
|
+
for (const begin of state.openBegins.values()) emitPreambleRecord(state, {
|
|
220
|
+
...begin,
|
|
221
|
+
carriedForward: true
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function emitPreambleRecord(state, record) {
|
|
225
|
+
try {
|
|
226
|
+
const line = `${JSON.stringify(record, bigintSafe)}\n`;
|
|
227
|
+
fs.writeSync(state.fd, line);
|
|
228
|
+
state.bytes += line.length;
|
|
229
|
+
state.preambleBytes += line.length;
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
function trackOpenOperation(state, record) {
|
|
233
|
+
if (record.type.endsWith("-begin")) {
|
|
234
|
+
if (state.openBegins.size < MAX_CARRIED_BEGINS) state.openBegins.set(record.seq, record);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (record.type.endsWith("-end") || record.type.endsWith("-error")) {
|
|
238
|
+
if (typeof record.begin === "number") state.openBegins.delete(record.begin);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function hasSessionEnd(file) {
|
|
242
|
+
const size = fs.statSync(file).size;
|
|
243
|
+
if (size === 0) return false;
|
|
244
|
+
const window = Math.min(size, 8192);
|
|
245
|
+
const buffer = Buffer.alloc(window);
|
|
246
|
+
const fd = fs.openSync(file, "r");
|
|
247
|
+
try {
|
|
248
|
+
fs.readSync(fd, buffer, 0, window, size - window);
|
|
249
|
+
} finally {
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
}
|
|
252
|
+
return buffer.toString("utf8").includes(`"type":"${SESSION_END_RECORD}"`);
|
|
253
|
+
}
|
|
254
|
+
function describeEnvironment() {
|
|
255
|
+
const maxOldSpaceFlag = process.execArgv.find((arg) => arg.startsWith("--max-old-space-size"));
|
|
256
|
+
return {
|
|
257
|
+
node: process.version,
|
|
258
|
+
platform: `${process.platform}-${process.arch}`,
|
|
259
|
+
cpus: os.cpus().length,
|
|
260
|
+
totalMemory: os.totalmem(),
|
|
261
|
+
heapLimit: v8.getHeapStatistics().heap_size_limit,
|
|
262
|
+
execArgv: [...process.execArgv],
|
|
263
|
+
maxOldSpaceSize: maxOldSpaceFlag ? Number(maxOldSpaceFlag.split("=")[1]) : void 0
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function bigintSafe(_key, value) {
|
|
267
|
+
return typeof value === "bigint" ? Number(value) : value;
|
|
268
|
+
}
|
|
269
|
+
function monotonic() {
|
|
270
|
+
return Math.round(performance.now() * 1e3) / 1e3;
|
|
271
|
+
}
|
|
272
|
+
function randomTag() {
|
|
273
|
+
return Math.random().toString(36).slice(2, 8);
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
export { listSessions, newSessionId, openRecorder, sessionIdFromFile, startSelfSampler };
|
|
277
|
+
|
|
278
|
+
//# sourceMappingURL=recorder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.js","names":[],"sources":["../src/recorder.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport v8 from \"node:v8\";\nimport {\n SESSION_FILE_PREFIX,\n MEM_BASELINE_RECORD,\n SESSION_END_RECORD,\n SESSION_RECORD,\n type LogRecord,\n type MemorySnapshot,\n type SessionEnvironment,\n} from \"./events\";\n\nexport type RecorderOptions = {\n /** Directory holding crash logs; created if absent. */\n dir: string;\n /** Which part of the app is recording, e.g. `middle-layer`. */\n role?: string;\n /** Free-form context stored in the session header (app version, project id). */\n meta?: Record<string, unknown>;\n /** Log is rotated past this size so the tail, which explains the crash, survives. */\n maxFileBytes?: number;\n /**\n * Session id assigned by a supervising parent, so the crash marker the parent\n * writes names this session with certainty rather than by inference.\n * Generated when absent.\n */\n sessionId?: string;\n};\n\nexport type Recorder = {\n readonly sessionId: string;\n readonly file: string;\n /** Appends one record and returns its sequence number. Never throws. */\n event(type: string, payload?: Record<string, unknown>): number;\n /**\n * Appends a record that every later segment of the log gets again.\n *\n * For a fact stated once that the rest of the log is unreadable without — what\n * a block id stands for, say. Written once per `key`; a rotation that discards\n * the original rewrites it, so a long session cannot outlive its own legend.\n *\n * Past a cap on how many such facts a session may carry, the record is still\n * written once and marked `notRetained`: it then survives only until the\n * rotation that discards it.\n */\n sticky(key: string, type: string, payload?: Record<string, unknown>): void;\n /** Memory reading for the calling thread; `rss` is process-wide. */\n memorySnapshot(): MemorySnapshot;\n /** Writes the terminating record. Its absence is how a crash is detected. */\n close(reason?: string): void;\n};\n\nexport type SessionFileInfo = {\n file: string;\n mtimeMs: number;\n bytes: number;\n /** True when the log has no terminating record, i.e. the process died. */\n crashed: boolean;\n};\n\ntype ParsedSession = {\n file: string;\n records: LogRecord[];\n /** True when the last line was cut mid-write by the kill. */\n truncatedTail: boolean;\n};\n\n/**\n * Opens a crash log for this process and writes the session header.\n *\n * Records are appended with a synchronous write rather than through a stream:\n * the process being recorded dies without warning — V8 fatal out-of-memory, a\n * failed native allocation, the OS out-of-memory killer — so no exit hook, no\n * flush and no `finally` block runs. Anything still sitting in a userspace\n * buffer is exactly the part that would have explained the death.\n */\nexport function openRecorder(options: RecorderOptions): Recorder {\n const {\n dir,\n role = \"middle-layer\",\n meta = {},\n maxFileBytes = 32 * 1024 * 1024,\n sessionId = newSessionId(),\n } = options;\n fs.mkdirSync(dir, { recursive: true });\n\n const file = path.join(dir, `${SESSION_FILE_PREFIX}-${sessionId}.ndjson`);\n const state: WriterState = {\n fd: fs.openSync(file, \"a\"),\n bytes: 0,\n preambleBytes: 0,\n seq: 0,\n closed: false,\n header: undefined,\n baselineMem: undefined,\n openBegins: new Map(),\n sticky: new Map(),\n };\n\n const memorySnapshot = (): MemorySnapshot => {\n const usage = process.memoryUsage();\n return {\n rss: usage.rss,\n heapUsed: usage.heapUsed,\n heapTotal: usage.heapTotal,\n external: usage.external,\n arrayBuffers: usage.arrayBuffers,\n heapLimit: v8.getHeapStatistics().heap_size_limit,\n };\n };\n\n const event = (type: string, payload: Record<string, unknown> = {}): number => {\n if (state.closed) return -1;\n const seq = ++state.seq;\n const record: LogRecord = {\n seq,\n t: monotonic(),\n wall: Date.now(),\n type,\n ...payload,\n };\n if (state.baselineMem === undefined && record.mem) state.baselineMem = record.mem;\n trackOpenOperation(state, record);\n writeLine(state, file, maxFileBytes, record);\n return seq;\n };\n\n const sticky = (key: string, type: string, payload?: Record<string, unknown>): void => {\n if (state.sticky.has(key)) return;\n // The preamble is rewritten in full at every rotation, so it cannot grow\n // without bound. Past the cap the fact is still written once — losing it\n // entirely would leave records naming a block nothing can identify — and\n // only its survival across rotation is given up. The record says so, so a\n // reader can tell a missing legend from one that was never written.\n const retained = state.sticky.size < MAX_STICKY_RECORDS;\n const seq = event(type, retained ? payload : { ...payload, notRetained: true });\n if (!retained) return;\n const record = { ...payload, seq, type } as LogRecord;\n state.sticky.set(key, record);\n };\n\n const recorder: Recorder = {\n sessionId,\n file,\n event,\n sticky,\n memorySnapshot,\n close(reason = \"normal\") {\n if (state.closed) return;\n event(SESSION_END_RECORD, { reason, mem: memorySnapshot() });\n state.closed = true;\n try {\n fs.closeSync(state.fd);\n } catch {\n // Closing an already-dead descriptor must not fail shutdown.\n }\n },\n };\n\n // Kept so a rotated log can be given the same header again: the active file\n // must describe its own session even if the parked segment is lost.\n state.header = { role, pid: process.pid, meta, env: describeEnvironment() };\n event(SESSION_RECORD, { ...state.header, mem: memorySnapshot() });\n\n return recorder;\n}\n\n/**\n * Periodic memory record written by the recorded thread itself.\n *\n * Doubles as a stall detector. This timer cannot fire while its thread is inside\n * a long synchronous call, so a gap here that the independent sampler thread\n * does not share means the thread was blocked — which is also why the heap\n * reading nearest a synchronous blow-up is always stale.\n */\nexport function startSelfSampler(recorder: Recorder, intervalMs = 500): () => void {\n let last = Date.now();\n const timer = setInterval(() => {\n const now = Date.now();\n recorder.event(\"mem-self\", {\n mem: recorder.memorySnapshot(),\n stallMs: Math.max(0, now - last - intervalMs),\n });\n last = now;\n }, intervalMs);\n timer.unref();\n return () => clearInterval(timer);\n}\n\n/** Crash logs in a directory, newest first, each flagged as crashed or clean. */\nexport function listSessions(dir: string): SessionFileInfo[] {\n let names: string[];\n try {\n names = fs.readdirSync(dir);\n } catch {\n return [];\n }\n return names\n .filter((name) => name.startsWith(`${SESSION_FILE_PREFIX}-`) && name.endsWith(\".ndjson\"))\n .map((name) => {\n const file = path.join(dir, name);\n const stat = fs.statSync(file);\n return { file, mtimeMs: stat.mtimeMs, bytes: stat.size, crashed: !hasSessionEnd(file) };\n })\n .sort((lhs, rhs) => rhs.mtimeMs - lhs.mtimeMs);\n}\n\n/**\n * Parses a crash log, tolerating a final line cut short by a hard kill.\n *\n * A rotated session spans two files: the parked `.1` segment holds the original\n * header and the earlier operations, the active file holds the tail. Both are\n * read so begin records in one segment can be paired with end records in the\n * other; sequence numbers run across the boundary.\n */\nexport function readSession(file: string): ParsedSession {\n const records: LogRecord[] = [];\n let truncatedTail = false;\n const parked = `${file}.1`;\n if (fs.existsSync(parked)) {\n for (const line of fs.readFileSync(parked, \"utf8\").split(\"\\n\")) {\n if (line === \"\") continue;\n try {\n records.push(JSON.parse(line) as LogRecord);\n } catch {\n // A damaged line in the parked segment costs one record, not the session.\n }\n }\n }\n const lines = fs.readFileSync(file, \"utf8\").split(\"\\n\");\n for (const [index, line] of lines.entries()) {\n if (line === \"\") continue;\n try {\n records.push(JSON.parse(line) as LogRecord);\n } catch {\n if (index >= lines.length - 2) truncatedTail = true;\n }\n }\n return { file, records, truncatedTail };\n}\n\n/**\n * Mints a session id. A parent that supervises a worker calls this, hands the id\n * to the worker, and keeps it for the crash marker, so both sides agree on the\n * session by construction. The leading timestamp is what\n * {@link sessionStartFromId} reads back.\n */\nexport function newSessionId(): string {\n return `${Date.now()}-${process.pid}-${randomTag()}`;\n}\n\n/** Wall-clock start of a session, taken from the id its file name carries. */\nexport function sessionStartFromId(sessionId: string): number {\n const start = Number(sessionId.split(\"-\")[0]);\n return Number.isFinite(start) ? start : 0;\n}\n\n/** Session id embedded in a crash log's file name. */\nexport function sessionIdFromFile(file: string): string {\n const match = path\n .basename(file)\n .match(new RegExp(`^${SESSION_FILE_PREFIX}-(.+)\\\\.ndjson(\\\\.1)?$`));\n return match ? match[1] : path.basename(file);\n}\n\n// Internals\n\n/** How many sticky records a session may keep, bounding the rewritten preamble. */\nconst MAX_STICKY_RECORDS = 64;\n\ntype WriterState = {\n fd: number;\n bytes: number;\n /** Bytes of the carried-forward preamble, which do not count toward the limit. */\n preambleBytes: number;\n seq: number;\n closed: boolean;\n header: Record<string, unknown> | undefined;\n /** Earliest memory reading of the session, so growth stays measurable. */\n baselineMem: MemorySnapshot | undefined;\n /** Begin records with no end yet, keyed by their sequence number. */\n openBegins: Map<number, LogRecord>;\n /** Records rewritten into every later segment, keyed by the caller's key. */\n sticky: Map<string, LogRecord>;\n};\n\n/** Cap on carried-forward begins, so a leak cannot make the preamble unbounded. */\nconst MAX_CARRIED_BEGINS = 256;\n\nfunction writeLine(\n state: WriterState,\n file: string,\n maxFileBytes: number,\n record: LogRecord,\n): void {\n let line: string;\n try {\n line = `${JSON.stringify(record, bigintSafe)}\\n`;\n } catch {\n line = `${JSON.stringify({ seq: record.seq, type: \"record-serialization-failed\" })}\\n`;\n }\n try {\n // The preamble is not charged against the limit, so a large preamble cannot\n // trigger another rotation on the very next write.\n if (state.bytes - state.preambleBytes + line.length > maxFileBytes) {\n rotate(state, file);\n writePreamble(state);\n }\n fs.writeSync(state.fd, line);\n state.bytes += line.length;\n } catch {\n // A recorder that cannot write stays silent rather than cascading into the\n // application it is only supposed to observe.\n }\n}\n\n// The tail is the only part that explains a crash, so the old file is parked\n// beside the new one instead of the new writes being dropped. Exactly one parked\n// segment is kept, which bounds a session's disk use at twice the file limit.\nfunction rotate(state: WriterState, file: string): void {\n fs.closeSync(state.fd);\n try {\n fs.renameSync(file, `${file}.1`);\n } catch {\n // If the parked slot cannot be written, recording simply continues.\n }\n state.fd = fs.openSync(file, \"a\");\n state.bytes = 0;\n state.preambleBytes = 0;\n}\n\n/**\n * Rewrites into the new segment the three things a report cannot be produced\n * without, so repeated rotation costs only completed operations.\n *\n * Losing an *open* begin record would remove that operation from pairing\n * entirely, and the operation still running at the moment of death is the one\n * the report exists to name. Losing the earliest memory reading would leave the\n * heap series starting mid-session while the sampler's resident series still\n * starts at zero, which biases the classifier toward blaming native memory.\n */\nfunction writePreamble(state: WriterState): void {\n if (state.header) {\n emitPreambleRecord(state, {\n seq: ++state.seq,\n t: monotonic(),\n wall: Date.now(),\n type: SESSION_RECORD,\n ...state.header,\n continuation: true,\n });\n }\n if (state.baselineMem) {\n emitPreambleRecord(state, {\n seq: ++state.seq,\n t: monotonic(),\n wall: Date.now(),\n type: MEM_BASELINE_RECORD,\n mem: state.baselineMem,\n carriedForward: true,\n });\n }\n for (const record of state.sticky.values()) {\n emitPreambleRecord(state, { ...record, carriedForward: true });\n }\n // Original sequence numbers are kept, which is what lets an end record in a\n // later segment pair with a begin first written in an overwritten one.\n for (const begin of state.openBegins.values()) {\n emitPreambleRecord(state, { ...begin, carriedForward: true });\n }\n}\n\nfunction emitPreambleRecord(state: WriterState, record: LogRecord): void {\n try {\n const line = `${JSON.stringify(record, bigintSafe)}\\n`;\n fs.writeSync(state.fd, line);\n state.bytes += line.length;\n state.preambleBytes += line.length;\n } catch {\n // A preamble that cannot be written must not stop the session.\n }\n}\n\n// Open operations are tracked by the same suffix convention the analyzer pairs\n// on, so the recorder needs no separate vocabulary for them.\nfunction trackOpenOperation(state: WriterState, record: LogRecord): void {\n if (record.type.endsWith(\"-begin\")) {\n if (state.openBegins.size < MAX_CARRIED_BEGINS) state.openBegins.set(record.seq, record);\n return;\n }\n if (record.type.endsWith(\"-end\") || record.type.endsWith(\"-error\")) {\n if (typeof record.begin === \"number\") state.openBegins.delete(record.begin);\n }\n}\n\nfunction hasSessionEnd(file: string): boolean {\n const size = fs.statSync(file).size;\n if (size === 0) return false;\n const window = Math.min(size, 8192);\n const buffer = Buffer.alloc(window);\n const fd = fs.openSync(file, \"r\");\n try {\n fs.readSync(fd, buffer, 0, window, size - window);\n } finally {\n fs.closeSync(fd);\n }\n return buffer.toString(\"utf8\").includes(`\"type\":\"${SESSION_END_RECORD}\"`);\n}\n\nfunction describeEnvironment(): SessionEnvironment {\n const maxOldSpaceFlag = process.execArgv.find((arg) => arg.startsWith(\"--max-old-space-size\"));\n return {\n node: process.version,\n platform: `${process.platform}-${process.arch}`,\n cpus: os.cpus().length,\n totalMemory: os.totalmem(),\n heapLimit: v8.getHeapStatistics().heap_size_limit,\n execArgv: [...process.execArgv],\n maxOldSpaceSize: maxOldSpaceFlag ? Number(maxOldSpaceFlag.split(\"=\")[1]) : undefined,\n };\n}\n\nfunction bigintSafe(_key: string, value: unknown): unknown {\n return typeof value === \"bigint\" ? Number(value) : value;\n}\n\nfunction monotonic(): number {\n return Math.round(performance.now() * 1000) / 1000;\n}\n\nfunction randomTag(): string {\n return Math.random().toString(36).slice(2, 8);\n}\n"],"mappings":"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"}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { summarizeData } from "./data_summary.js";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
//#region src/redact.ts
|
|
4
|
+
/**
|
|
5
|
+
* Structure-preserving redaction for anything a driver seam is handed.
|
|
6
|
+
*
|
|
7
|
+
* The definition a block model builds is recorded by *shape* rather than by a
|
|
8
|
+
* hand-written digest per definition type. Keys, numbers and the small set of
|
|
9
|
+
* strings that are schema survive verbatim; every other string is replaced by a
|
|
10
|
+
* hash and a length. That keeps the record useful for diagnosis, keeps customer
|
|
11
|
+
* data out of it by default rather than by enumeration, and works unchanged for
|
|
12
|
+
* definition shapes this code has never seen — the V2 query API included.
|
|
13
|
+
*
|
|
14
|
+
* The default for an unrecognised string is to hash it. A new field can
|
|
15
|
+
* therefore make a report less informative, but never make it leak.
|
|
16
|
+
*/
|
|
17
|
+
/** Keys whose string value is schema, kept as written. */
|
|
18
|
+
const SCHEMA_KEYS = /* @__PURE__ */ new Set([
|
|
19
|
+
"type",
|
|
20
|
+
"name",
|
|
21
|
+
"valueType",
|
|
22
|
+
"kind",
|
|
23
|
+
"operator",
|
|
24
|
+
"mode"
|
|
25
|
+
]);
|
|
26
|
+
/** Keys under which every string is schema, at any depth (axis identity). */
|
|
27
|
+
const SCHEMA_SUBTREE_KEYS = /* @__PURE__ */ new Set(["domain", "contextDomain"]);
|
|
28
|
+
/** Keys never descended into; summarised by counts instead. */
|
|
29
|
+
const SUMMARISED_KEYS = /* @__PURE__ */ new Set(["data", "dataInfo"]);
|
|
30
|
+
/** Keys reduced to a cardinality, because their contents are values. */
|
|
31
|
+
const COUNTED_KEYS = /* @__PURE__ */ new Set(["references", "parts"]);
|
|
32
|
+
/** Redacts a definition, returning the new value and what had to be elided. */
|
|
33
|
+
function redact(value, options = {}) {
|
|
34
|
+
const limits = {
|
|
35
|
+
maxDepth: options.maxDepth ?? 32,
|
|
36
|
+
maxArrayItems: options.maxArrayItems ?? 64,
|
|
37
|
+
maxStringLength: options.maxStringLength ?? 128,
|
|
38
|
+
maxNodes: options.maxNodes ?? 2e4
|
|
39
|
+
};
|
|
40
|
+
const stats = {
|
|
41
|
+
hashedStrings: 0,
|
|
42
|
+
truncatedArrays: 0,
|
|
43
|
+
omittedItems: 0,
|
|
44
|
+
depthCapped: 0,
|
|
45
|
+
opaqueObjects: 0,
|
|
46
|
+
budgetExhausted: false
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
value: walk(value, {
|
|
50
|
+
key: void 0,
|
|
51
|
+
schemaSubtree: false,
|
|
52
|
+
depth: 0
|
|
53
|
+
}, limits, stats, {
|
|
54
|
+
nodes: 0,
|
|
55
|
+
seen: /* @__PURE__ */ new WeakSet()
|
|
56
|
+
}),
|
|
57
|
+
stats
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Stable short hash plus the original length. Never reversible to the value. */
|
|
61
|
+
function hashString(value) {
|
|
62
|
+
return {
|
|
63
|
+
h: crypto.createHash("sha256").update(value).digest("hex").slice(0, 12),
|
|
64
|
+
n: value.length
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function walk(value, at, limits, stats, state) {
|
|
68
|
+
if (state.nodes++ > limits.maxNodes) {
|
|
69
|
+
stats.budgetExhausted = true;
|
|
70
|
+
return { $budget: true };
|
|
71
|
+
}
|
|
72
|
+
if (value === null || value === void 0) return value ?? null;
|
|
73
|
+
if (typeof value === "bigint") return Number(value);
|
|
74
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
75
|
+
if (typeof value === "string") return redactString(value, at, limits, stats);
|
|
76
|
+
if (typeof value !== "object") return { $type: typeof value };
|
|
77
|
+
if (at.depth >= limits.maxDepth) {
|
|
78
|
+
stats.depthCapped++;
|
|
79
|
+
return { $depth: at.depth };
|
|
80
|
+
}
|
|
81
|
+
if (state.seen.has(value)) return { $cycle: true };
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
state.seen.add(value);
|
|
84
|
+
return walkArray(value, at, limits, stats, state);
|
|
85
|
+
}
|
|
86
|
+
if (!isPlainObject(value)) {
|
|
87
|
+
stats.opaqueObjects++;
|
|
88
|
+
return { $opaque: className(value) };
|
|
89
|
+
}
|
|
90
|
+
state.seen.add(value);
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const [key, child] of Object.entries(value)) {
|
|
93
|
+
if (SUMMARISED_KEYS.has(key)) {
|
|
94
|
+
out[key] = summarizeData(child);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (COUNTED_KEYS.has(key)) {
|
|
98
|
+
out[key] = { $count: countOf(child) };
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out[key] = walk(child, {
|
|
102
|
+
key,
|
|
103
|
+
schemaSubtree: at.schemaSubtree || SCHEMA_SUBTREE_KEYS.has(key),
|
|
104
|
+
depth: at.depth + 1
|
|
105
|
+
}, limits, stats, state);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
function walkArray(value, at, limits, stats, state) {
|
|
110
|
+
const kept = value.slice(0, limits.maxArrayItems).map((item) => walk(item, {
|
|
111
|
+
key: at.key,
|
|
112
|
+
schemaSubtree: at.schemaSubtree,
|
|
113
|
+
depth: at.depth + 1
|
|
114
|
+
}, limits, stats, state));
|
|
115
|
+
const omitted = value.length - kept.length;
|
|
116
|
+
if (omitted <= 0) return kept;
|
|
117
|
+
stats.truncatedArrays++;
|
|
118
|
+
stats.omittedItems += omitted;
|
|
119
|
+
return [...kept, { $omitted: omitted }];
|
|
120
|
+
}
|
|
121
|
+
function redactString(value, at, limits, stats) {
|
|
122
|
+
if (at.schemaSubtree || at.key !== void 0 && SCHEMA_KEYS.has(at.key)) return value.length > limits.maxStringLength ? `${value.slice(0, limits.maxStringLength)}…` : value;
|
|
123
|
+
stats.hashedStrings++;
|
|
124
|
+
return hashString(value);
|
|
125
|
+
}
|
|
126
|
+
function countOf(value) {
|
|
127
|
+
if (Array.isArray(value)) return value.length;
|
|
128
|
+
if (isPlainObject(value)) return Object.keys(value).length;
|
|
129
|
+
}
|
|
130
|
+
function isPlainObject(value) {
|
|
131
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
132
|
+
const proto = Object.getPrototypeOf(value);
|
|
133
|
+
return proto === Object.prototype || proto === null;
|
|
134
|
+
}
|
|
135
|
+
function className(value) {
|
|
136
|
+
return value.constructor?.name ?? "unknown";
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { redact };
|
|
140
|
+
|
|
141
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redact.js","names":[],"sources":["../src/redact.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport { summarizeData, type DataSummary } from \"./data_summary\";\n\n/**\n * Structure-preserving redaction for anything a driver seam is handed.\n *\n * The definition a block model builds is recorded by *shape* rather than by a\n * hand-written digest per definition type. Keys, numbers and the small set of\n * strings that are schema survive verbatim; every other string is replaced by a\n * hash and a length. That keeps the record useful for diagnosis, keeps customer\n * data out of it by default rather than by enumeration, and works unchanged for\n * definition shapes this code has never seen — the V2 query API included.\n *\n * The default for an unrecognised string is to hash it. A new field can\n * therefore make a report less informative, but never make it leak.\n */\n\n/** Keys whose string value is schema, kept as written. */\nconst SCHEMA_KEYS = new Set([\"type\", \"name\", \"valueType\", \"kind\", \"operator\", \"mode\"]);\n\n/** Keys under which every string is schema, at any depth (axis identity). */\nconst SCHEMA_SUBTREE_KEYS = new Set([\"domain\", \"contextDomain\"]);\n\n/** Keys never descended into; summarised by counts instead. */\nconst SUMMARISED_KEYS = new Set([\"data\", \"dataInfo\"]);\n\n/** Keys reduced to a cardinality, because their contents are values. */\nconst COUNTED_KEYS = new Set([\"references\", \"parts\"]);\n\nexport type RedactionStats = {\n hashedStrings: number;\n truncatedArrays: number;\n omittedItems: number;\n depthCapped: number;\n opaqueObjects: number;\n budgetExhausted: boolean;\n};\n\ntype RedactOptions = {\n maxDepth?: number;\n maxArrayItems?: number;\n maxStringLength?: number;\n /** Ceiling on emitted values, so one pathological definition cannot fill the log. */\n maxNodes?: number;\n};\n\ntype HashedString = { h: string; n: number };\n\n/** Redacts a definition, returning the new value and what had to be elided. */\nexport function redact(\n value: unknown,\n options: RedactOptions = {},\n): { value: unknown; stats: RedactionStats } {\n const limits = {\n maxDepth: options.maxDepth ?? 32,\n maxArrayItems: options.maxArrayItems ?? 64,\n maxStringLength: options.maxStringLength ?? 128,\n maxNodes: options.maxNodes ?? 20_000,\n };\n const stats: RedactionStats = {\n hashedStrings: 0,\n truncatedArrays: 0,\n omittedItems: 0,\n depthCapped: 0,\n opaqueObjects: 0,\n budgetExhausted: false,\n };\n const state = { nodes: 0, seen: new WeakSet<object>() };\n return {\n value: walk(value, { key: undefined, schemaSubtree: false, depth: 0 }, limits, stats, state),\n stats,\n };\n}\n\n/** Stable short hash plus the original length. Never reversible to the value. */\nfunction hashString(value: string): HashedString {\n return {\n h: crypto.createHash(\"sha256\").update(value).digest(\"hex\").slice(0, 12),\n n: value.length,\n };\n}\n\n/** True for the object form produced in place of a redacted string. */\nexport function isHashedString(value: unknown): value is HashedString {\n return typeof value === \"object\" && value !== null && \"h\" in value && \"n\" in value;\n}\n\n// Internals\n\ntype Position = { key: string | undefined; schemaSubtree: boolean; depth: number };\ntype Limits = Required<RedactOptions>;\ntype State = { nodes: number; seen: WeakSet<object> };\n\nfunction walk(\n value: unknown,\n at: Position,\n limits: Limits,\n stats: RedactionStats,\n state: State,\n): unknown {\n if (state.nodes++ > limits.maxNodes) {\n stats.budgetExhausted = true;\n return { $budget: true };\n }\n\n if (value === null || value === undefined) return value ?? null;\n if (typeof value === \"bigint\") return Number(value);\n if (typeof value === \"number\" || typeof value === \"boolean\") return value;\n if (typeof value === \"string\") return redactString(value, at, limits, stats);\n if (typeof value !== \"object\") return { $type: typeof value };\n\n if (at.depth >= limits.maxDepth) {\n stats.depthCapped++;\n return { $depth: at.depth };\n }\n // A definition can carry live accessors and other class instances whose\n // internals reference each other; walking those is neither safe nor useful.\n if (state.seen.has(value)) return { $cycle: true };\n\n if (Array.isArray(value)) {\n state.seen.add(value);\n return walkArray(value, at, limits, stats, state);\n }\n if (!isPlainObject(value)) {\n stats.opaqueObjects++;\n return { $opaque: className(value) };\n }\n\n state.seen.add(value);\n const out: Record<string, unknown> = {};\n for (const [key, child] of Object.entries(value)) {\n if (SUMMARISED_KEYS.has(key)) {\n out[key] = summarizeData(child);\n continue;\n }\n if (COUNTED_KEYS.has(key)) {\n out[key] = { $count: countOf(child) };\n continue;\n }\n out[key] = walk(\n child,\n {\n key,\n schemaSubtree: at.schemaSubtree || SCHEMA_SUBTREE_KEYS.has(key),\n depth: at.depth + 1,\n },\n limits,\n stats,\n state,\n );\n }\n return out;\n}\n\nfunction walkArray(\n value: unknown[],\n at: Position,\n limits: Limits,\n stats: RedactionStats,\n state: State,\n): unknown[] {\n const kept = value\n .slice(0, limits.maxArrayItems)\n .map((item) =>\n walk(\n item,\n { key: at.key, schemaSubtree: at.schemaSubtree, depth: at.depth + 1 },\n limits,\n stats,\n state,\n ),\n );\n const omitted = value.length - kept.length;\n if (omitted <= 0) return kept;\n // Arrays stay arrays so the rules can still walk join entries; the loss is\n // recorded in the array itself rather than in a side channel.\n stats.truncatedArrays++;\n stats.omittedItems += omitted;\n return [...kept, { $omitted: omitted }];\n}\n\nfunction redactString(\n value: string,\n at: Position,\n limits: Limits,\n stats: RedactionStats,\n): string | HashedString {\n if (at.schemaSubtree || (at.key !== undefined && SCHEMA_KEYS.has(at.key))) {\n return value.length > limits.maxStringLength\n ? `${value.slice(0, limits.maxStringLength)}…`\n : value;\n }\n stats.hashedStrings++;\n return hashString(value);\n}\n\nfunction countOf(value: unknown): number | undefined {\n if (Array.isArray(value)) return value.length;\n if (isPlainObject(value)) return Object.keys(value).length;\n return undefined;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n const proto = Object.getPrototypeOf(value) as object | null;\n return proto === Object.prototype || proto === null;\n}\n\nfunction className(value: object): string {\n return (value as { constructor?: { name?: string } }).constructor?.name ?? \"unknown\";\n}\n\nexport type { DataSummary };\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sampler.d.ts","names":[],"sources":["../src/sampler.ts"],"mappings":";YAWY;;WAED;EACT"}
|
package/dist/sampler.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SAMPLER_FILE_PREFIX } from "./events.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Worker } from "node:worker_threads";
|
|
4
|
+
//#region src/sampler.ts
|
|
5
|
+
/**
|
|
6
|
+
* Starts the out-of-band memory sampler for a session.
|
|
7
|
+
*
|
|
8
|
+
* The sampler runs on a thread of its own with a small heap of its own, so it
|
|
9
|
+
* keeps producing readings when the observed thread is blocked and when the
|
|
10
|
+
* observed thread's heap is the thing that is full.
|
|
11
|
+
*/
|
|
12
|
+
function startMemorySampler(options) {
|
|
13
|
+
const { dir, sessionId, intervalMs = 250 } = options;
|
|
14
|
+
const file = path.join(dir, `${SAMPLER_FILE_PREFIX}-${sessionId}.ndjson`);
|
|
15
|
+
const worker = new Worker(new URL("./sampler_thread.js", import.meta.url), {
|
|
16
|
+
workerData: {
|
|
17
|
+
file,
|
|
18
|
+
intervalMs
|
|
19
|
+
},
|
|
20
|
+
resourceLimits: { maxOldGenerationSizeMb: 32 }
|
|
21
|
+
});
|
|
22
|
+
worker.unref();
|
|
23
|
+
return {
|
|
24
|
+
file,
|
|
25
|
+
stop: () => {
|
|
26
|
+
worker.terminate();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { startMemorySampler };
|
|
32
|
+
|
|
33
|
+
//# sourceMappingURL=sampler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sampler.js","names":[],"sources":["../src/sampler.ts"],"sourcesContent":["import path from \"node:path\";\nimport { Worker } from \"node:worker_threads\";\nimport { SAMPLER_FILE_PREFIX } from \"./events\";\n\ntype MemorySamplerOptions = {\n dir: string;\n sessionId: string;\n /** Sampling period; 250 ms is roughly four short appends per second. */\n intervalMs?: number;\n};\n\nexport type MemorySampler = {\n /** Sibling log the sampler appends to. */\n readonly file: string;\n stop(): void;\n};\n\n/**\n * Starts the out-of-band memory sampler for a session.\n *\n * The sampler runs on a thread of its own with a small heap of its own, so it\n * keeps producing readings when the observed thread is blocked and when the\n * observed thread's heap is the thing that is full.\n */\nexport function startMemorySampler(options: MemorySamplerOptions): MemorySampler {\n const { dir, sessionId, intervalMs = 250 } = options;\n const file = path.join(dir, `${SAMPLER_FILE_PREFIX}-${sessionId}.ndjson`);\n const worker = new Worker(new URL(\"./sampler_thread.js\", import.meta.url), {\n workerData: { file, intervalMs },\n resourceLimits: { maxOldGenerationSizeMb: 32 },\n });\n // Unreferenced so a sampler that is never stopped cannot hold the process open.\n worker.unref();\n return {\n file,\n stop: () => {\n void worker.terminate();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;AAwBA,SAAgB,mBAAmB,SAA8C;CAC/E,MAAM,EAAE,KAAK,WAAW,aAAa,QAAQ;CAC7C,MAAM,OAAO,KAAK,KAAK,KAAK,GAAG,oBAAoB,GAAG,UAAU,QAAQ;CACxE,MAAM,SAAS,IAAI,OAAO,IAAI,IAAI,uBAAuB,OAAO,KAAK,GAAG,GAAG;EACzE,YAAY;GAAE;GAAM;EAAW;EAC/B,gBAAgB,EAAE,wBAAwB,GAAG;CAC/C,CAAC;CAED,OAAO,MAAM;CACb,OAAO;EACL;EACA,YAAY;GACV,OAAY,UAAU;EACxB;CACF;AACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readMachineMemory } from "./machine_memory.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { workerData } from "node:worker_threads";
|
|
5
|
+
//#region src/sampler_thread.ts
|
|
6
|
+
/**
|
|
7
|
+
* Memory sampler, run on its own worker thread.
|
|
8
|
+
*
|
|
9
|
+
* It exists because the thread worth watching is the one that blocks. While the
|
|
10
|
+
* middle layer sits inside a synchronous pframes call its own timers do not
|
|
11
|
+
* fire, so its memory series goes dark exactly while memory is growing fastest.
|
|
12
|
+
* This thread stays responsive and keeps the resident-size curve intact right up
|
|
13
|
+
* to the moment the process dies.
|
|
14
|
+
*
|
|
15
|
+
* `rss` and `freeMemory` are process- and machine-wide and so are meaningful
|
|
16
|
+
* from here. Heap figures are per-isolate and would describe only this thread,
|
|
17
|
+
* so they are deliberately not recorded; the observed thread reports its own.
|
|
18
|
+
*
|
|
19
|
+
* Resident size alone understates a process under pressure, because the OS moves
|
|
20
|
+
* its pages into the compressor or out to swap and they stop being resident. The
|
|
21
|
+
* machine's own account of where memory went is therefore sampled alongside it,
|
|
22
|
+
* less often because it costs a subprocess.
|
|
23
|
+
*/
|
|
24
|
+
const { file, intervalMs, machineIntervalMs = 1e3 } = workerData;
|
|
25
|
+
const fd = fs.openSync(file, "a");
|
|
26
|
+
let seq = 0;
|
|
27
|
+
let peakRss = 0;
|
|
28
|
+
let machineDueAt = 0;
|
|
29
|
+
setInterval(() => {
|
|
30
|
+
const rss = process.memoryUsage.rss();
|
|
31
|
+
if (rss > peakRss) peakRss = rss;
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const machine = now >= machineDueAt ? readMachineMemory() : void 0;
|
|
34
|
+
if (machine) machineDueAt = now + machineIntervalMs;
|
|
35
|
+
const record = {
|
|
36
|
+
seq: ++seq,
|
|
37
|
+
t: Math.round(performance.now() * 1e3) / 1e3,
|
|
38
|
+
wall: now,
|
|
39
|
+
type: "mem-sampler",
|
|
40
|
+
rss,
|
|
41
|
+
peakRss,
|
|
42
|
+
maxRss: process.resourceUsage().maxRSS * 1024,
|
|
43
|
+
freeMemory: os.freemem(),
|
|
44
|
+
totalMemory: os.totalmem(),
|
|
45
|
+
...machine ? { machine } : {}
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
fs.writeSync(fd, `${JSON.stringify(record)}\n`);
|
|
49
|
+
} catch {}
|
|
50
|
+
}, intervalMs);
|
|
51
|
+
//#endregion
|
|
52
|
+
|
|
53
|
+
//# sourceMappingURL=sampler_thread.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sampler_thread.js","names":[],"sources":["../src/sampler_thread.ts"],"sourcesContent":["/**\n * Memory sampler, run on its own worker thread.\n *\n * It exists because the thread worth watching is the one that blocks. While the\n * middle layer sits inside a synchronous pframes call its own timers do not\n * fire, so its memory series goes dark exactly while memory is growing fastest.\n * This thread stays responsive and keeps the resident-size curve intact right up\n * to the moment the process dies.\n *\n * `rss` and `freeMemory` are process- and machine-wide and so are meaningful\n * from here. Heap figures are per-isolate and would describe only this thread,\n * so they are deliberately not recorded; the observed thread reports its own.\n *\n * Resident size alone understates a process under pressure, because the OS moves\n * its pages into the compressor or out to swap and they stop being resident. The\n * machine's own account of where memory went is therefore sampled alongside it,\n * less often because it costs a subprocess.\n */\n\nimport fs from \"node:fs\";\nimport os from \"node:os\";\nimport { workerData } from \"node:worker_threads\";\nimport type { SamplerRecord } from \"./events\";\nimport { readMachineMemory } from \"./machine_memory\";\n\ntype SamplerWorkerData = { file: string; intervalMs: number; machineIntervalMs?: number };\n\nconst { file, intervalMs, machineIntervalMs = 1000 } = workerData as SamplerWorkerData;\nconst fd = fs.openSync(file, \"a\");\nlet seq = 0;\nlet peakRss = 0;\nlet machineDueAt = 0;\n\nsetInterval(() => {\n const rss = process.memoryUsage.rss();\n if (rss > peakRss) peakRss = rss;\n const now = Date.now();\n // Taken on the first tick and then on its own schedule, so the curve keeps its\n // sampling rate while the costlier reading stays occasional.\n const machine = now >= machineDueAt ? readMachineMemory() : undefined;\n if (machine) machineDueAt = now + machineIntervalMs;\n const record: SamplerRecord = {\n seq: ++seq,\n t: Math.round(performance.now() * 1000) / 1000,\n wall: now,\n type: \"mem-sampler\",\n rss,\n peakRss,\n // The kernel's own high-water mark, which no sampling interval can miss.\n maxRss: process.resourceUsage().maxRSS * 1024,\n freeMemory: os.freemem(),\n totalMemory: os.totalmem(),\n ...(machine ? { machine } : {}),\n };\n try {\n fs.writeSync(fd, `${JSON.stringify(record)}\\n`);\n } catch {\n // Sampling must never take the application down.\n }\n}, intervalMs);\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAM,EAAE,MAAM,YAAY,oBAAoB,QAAS;AACvD,MAAM,KAAK,GAAG,SAAS,MAAM,GAAG;AAChC,IAAI,MAAM;AACV,IAAI,UAAU;AACd,IAAI,eAAe;AAEnB,kBAAkB;CAChB,MAAM,MAAM,QAAQ,YAAY,IAAI;CACpC,IAAI,MAAM,SAAS,UAAU;CAC7B,MAAM,MAAM,KAAK,IAAI;CAGrB,MAAM,UAAU,OAAO,eAAe,kBAAkB,IAAI,KAAA;CAC5D,IAAI,SAAS,eAAe,MAAM;CAClC,MAAM,SAAwB;EAC5B,KAAK,EAAE;EACP,GAAG,KAAK,MAAM,YAAY,IAAI,IAAI,GAAI,IAAI;EAC1C,MAAM;EACN,MAAM;EACN;EACA;EAEA,QAAQ,QAAQ,cAAc,CAAC,CAAC,SAAS;EACzC,YAAY,GAAG,QAAQ;EACvB,aAAa,GAAG,SAAS;EACzB,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;CAC/B;CACA,IAAI;EACF,GAAG,UAAU,IAAI,GAAG,KAAK,UAAU,MAAM,EAAE,GAAG;CAChD,QAAQ,CAER;AACF,GAAG,UAAU"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Recorder } from "./recorder.js";
|
|
2
|
+
import { MemorySampler } from "./sampler.js";
|
|
3
|
+
import { HandleRegistry } from "./instrument.js";
|
|
4
|
+
//#region src/session.d.ts
|
|
5
|
+
/** Environment variable naming the directory crash logs are written to. */
|
|
6
|
+
export declare const CRASH_DIR_ENV = "MI_CRASH_RECORDER_DIR";
|
|
7
|
+
/**
|
|
8
|
+
* Environment variable carrying the session id a supervising parent assigned.
|
|
9
|
+
* Set it alongside {@link CRASH_DIR_ENV} when spawning the worker and pass the
|
|
10
|
+
* same id to `superviseWorker`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CRASH_SESSION_ENV = "MI_CRASH_RECORDER_SESSION";
|
|
13
|
+
export type RecordingSessionOptions = {
|
|
14
|
+
/** Overrides the directory from the environment. */
|
|
15
|
+
dir?: string;
|
|
16
|
+
/** Overrides the session id from the environment. */
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
role?: string;
|
|
19
|
+
meta?: Record<string, unknown>;
|
|
20
|
+
samplerIntervalMs?: number;
|
|
21
|
+
selfSamplerIntervalMs?: number;
|
|
22
|
+
};
|
|
23
|
+
export type RecordingSession = {
|
|
24
|
+
readonly recorder: Recorder;
|
|
25
|
+
readonly sampler: MemorySampler;
|
|
26
|
+
/** Shared so create calls and later data calls agree on handle identity. */
|
|
27
|
+
readonly registry: HandleRegistry;
|
|
28
|
+
close(reason?: string): void;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Opens a recording session, or returns undefined when recording is not enabled.
|
|
32
|
+
*
|
|
33
|
+
* Recording is opt-in for now: it appends synchronously on every recorded
|
|
34
|
+
* operation, and that cost has not been measured against a real project, so it
|
|
35
|
+
* is switched on by pointing {@link CRASH_DIR_ENV} at a directory rather than
|
|
36
|
+
* being on by default.
|
|
37
|
+
*/
|
|
38
|
+
export declare function openRecordingSession(options?: RecordingSessionOptions): RecordingSession | undefined;
|
|
39
|
+
//#endregion
|
|
40
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","names":[],"sources":["../src/session.ts"],"mappings":";;;;;qBAKa;;;;;;qBAOA;YAED;;EAEV;;EAEA;EACA;EACA,OAAO;EACP;EACA;;YAGU;WACD,UAAU;WACV,SAAS;;WAET,UAAU;EACnB,MAAM;;;;;;;;;;wBAWQ,qBACd,UAAS,0BACR"}
|