@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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { HOST_FILE_PREFIX, type HostRecord } from "./events";
|
|
4
|
+
|
|
5
|
+
export type HostReading = Omit<HostRecord, "seq" | "t" | "wall" | "type">;
|
|
6
|
+
|
|
7
|
+
export type HostSamplerOptions = {
|
|
8
|
+
dir: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
/**
|
|
11
|
+
* Takes one reading. Supplied by the caller because the useful numbers come
|
|
12
|
+
* from the application framework rather than from Node, and this package must
|
|
13
|
+
* not depend on it.
|
|
14
|
+
*/
|
|
15
|
+
read: () => HostReading | Promise<HostReading>;
|
|
16
|
+
/** Sampling period; these readings change slowly and cost more than resident size. */
|
|
17
|
+
intervalMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type HostSampler = {
|
|
21
|
+
/** Sibling log this sampler appends to. */
|
|
22
|
+
readonly file: string;
|
|
23
|
+
stop(): void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Records what only the host process can measure, beside the session it hosts.
|
|
28
|
+
*
|
|
29
|
+
* Its own file rather than the session log: that log belongs to another thread
|
|
30
|
+
* and has its own descriptor and sequence, and two writers sharing them would
|
|
31
|
+
* corrupt both. Readers join the two by wall clock, as they already do with the
|
|
32
|
+
* memory sampler.
|
|
33
|
+
*
|
|
34
|
+
* This one runs wherever the caller runs, so a blocked host stops it — which is
|
|
35
|
+
* why it supplements the sampler thread rather than replacing it. What it adds
|
|
36
|
+
* is attribution, and attribution a second stale is still attribution.
|
|
37
|
+
*/
|
|
38
|
+
export function startHostSampler(options: HostSamplerOptions): HostSampler {
|
|
39
|
+
const { dir, sessionId, read, intervalMs = 1000 } = options;
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
const file = path.join(dir, `${HOST_FILE_PREFIX}-${sessionId}.ndjson`);
|
|
42
|
+
const fd = fs.openSync(file, "a");
|
|
43
|
+
let seq = 0;
|
|
44
|
+
let writing = false;
|
|
45
|
+
|
|
46
|
+
const timer = setInterval(() => {
|
|
47
|
+
// A reading that outlives its interval must not queue up behind itself.
|
|
48
|
+
if (writing) return;
|
|
49
|
+
writing = true;
|
|
50
|
+
void Promise.resolve()
|
|
51
|
+
.then(read)
|
|
52
|
+
.then((reading) => {
|
|
53
|
+
const record: HostRecord = {
|
|
54
|
+
seq: ++seq,
|
|
55
|
+
t: Math.round(performance.now() * 1000) / 1000,
|
|
56
|
+
wall: Date.now(),
|
|
57
|
+
type: "mem-host",
|
|
58
|
+
...reading,
|
|
59
|
+
};
|
|
60
|
+
fs.writeSync(fd, `${JSON.stringify(record)}\n`);
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {
|
|
63
|
+
// Sampling must never take the application down.
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
writing = false;
|
|
67
|
+
});
|
|
68
|
+
}, intervalMs);
|
|
69
|
+
// Unreferenced so a sampler that is never stopped cannot hold the host open.
|
|
70
|
+
timer.unref();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
file,
|
|
74
|
+
stop: () => {
|
|
75
|
+
clearInterval(timer);
|
|
76
|
+
try {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
} catch {
|
|
79
|
+
// Closing an already-dead descriptor must not fail shutdown.
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export {
|
|
2
|
+
openRecorder,
|
|
3
|
+
newSessionId,
|
|
4
|
+
listSessions,
|
|
5
|
+
sessionIdFromFile,
|
|
6
|
+
type Recorder,
|
|
7
|
+
type RecorderOptions,
|
|
8
|
+
type SessionFileInfo,
|
|
9
|
+
} from "./recorder";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
openRecordingSession,
|
|
13
|
+
CRASH_DIR_ENV,
|
|
14
|
+
CRASH_SESSION_ENV,
|
|
15
|
+
type RecordingSession,
|
|
16
|
+
type RecordingSessionOptions,
|
|
17
|
+
} from "./session";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
startHostSampler,
|
|
21
|
+
type HostSampler,
|
|
22
|
+
type HostSamplerOptions,
|
|
23
|
+
type HostReading,
|
|
24
|
+
} from "./host_sampler";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
readCrashMarkers,
|
|
28
|
+
superviseWorker,
|
|
29
|
+
type SupervisedWorker,
|
|
30
|
+
type SuperviseOptions,
|
|
31
|
+
} from "./supervisor";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
wrapModelDriver,
|
|
35
|
+
wrapDataDriver,
|
|
36
|
+
recordModelRenderSync,
|
|
37
|
+
createHandleRegistry,
|
|
38
|
+
type HandleRegistry,
|
|
39
|
+
type RenderInfo,
|
|
40
|
+
} from "./instrument";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
type LogRecord,
|
|
44
|
+
type MemorySnapshot,
|
|
45
|
+
type SamplerRecord,
|
|
46
|
+
type HostRecord,
|
|
47
|
+
type MachineMemory,
|
|
48
|
+
type CrashMarker,
|
|
49
|
+
type CrashReason,
|
|
50
|
+
type SessionEnvironment,
|
|
51
|
+
} from "./events";
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import type { PColumn, PTableDef, PTableDefV2 } from "@milaboratories/pl-model-common";
|
|
2
|
+
import { digestDef, type DefDigest, type DefKind } from "./digest";
|
|
3
|
+
import { redact } from "./redact";
|
|
4
|
+
import type { Recorder } from "./recorder";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrappers for the seams the model layer passes through.
|
|
8
|
+
*
|
|
9
|
+
* Every operation writes a begin record and an end record. That pairing is what
|
|
10
|
+
* makes a crash legible: when the process dies mid-operation the end record is
|
|
11
|
+
* missing, so the log names the exact call that was running when memory ran out
|
|
12
|
+
* — the question a post-crash report has to answer.
|
|
13
|
+
*
|
|
14
|
+
* The wrappers are structural rather than tied to one driver interface, because
|
|
15
|
+
* the same three creation methods appear twice with different return types: the
|
|
16
|
+
* model-facing driver hands back a bare handle, the internal one hands back a
|
|
17
|
+
* pool entry.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type HandleOrigin = {
|
|
21
|
+
/** Sequence number of the record holding the definition. */
|
|
22
|
+
seq: number;
|
|
23
|
+
op: string;
|
|
24
|
+
observed?: { rows?: number; columns?: number };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type HandleRegistry = {
|
|
28
|
+
put(handle: string, origin: HandleOrigin): void;
|
|
29
|
+
get(handle: string): HandleOrigin | undefined;
|
|
30
|
+
observe(handle: string, observed: { rows?: number; columns?: number }): void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ModelDriverLike<H> = {
|
|
34
|
+
createPFrame(def: never): H;
|
|
35
|
+
createPTable(def: never): H;
|
|
36
|
+
createPTableV2(def: never): H;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type RenderInfo = {
|
|
40
|
+
blockId?: string;
|
|
41
|
+
/** What the block is, as `organization:name` — not the id it has in a project. */
|
|
42
|
+
block?: string;
|
|
43
|
+
blockVersion?: string;
|
|
44
|
+
/** Where the block came from: a registry, a local pack, a dev folder. */
|
|
45
|
+
blockSource?: string;
|
|
46
|
+
/** SDK the block's model was built against. */
|
|
47
|
+
sdkVersion?: string;
|
|
48
|
+
key?: string;
|
|
49
|
+
argsHash?: string;
|
|
50
|
+
/** Which lambda of the block's model is being rendered. */
|
|
51
|
+
lambda?: string;
|
|
52
|
+
/** Nth resumption of a deferred render, counted from one. */
|
|
53
|
+
recalculation?: number;
|
|
54
|
+
/** Read after the render, so sandbox counters cover the whole call. */
|
|
55
|
+
getStats?: () => unknown;
|
|
56
|
+
/** Any further context the call site wants on the record. */
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Maps driver handles back to the join that produced them. */
|
|
61
|
+
export function createHandleRegistry(limit = 512): HandleRegistry {
|
|
62
|
+
const map = new Map<string, HandleOrigin>();
|
|
63
|
+
return {
|
|
64
|
+
put(handle, origin) {
|
|
65
|
+
if (map.size >= limit) {
|
|
66
|
+
const oldest = map.keys().next();
|
|
67
|
+
if (!oldest.done) map.delete(oldest.value);
|
|
68
|
+
}
|
|
69
|
+
map.set(handle, origin);
|
|
70
|
+
},
|
|
71
|
+
get(handle) {
|
|
72
|
+
return map.get(handle);
|
|
73
|
+
},
|
|
74
|
+
observe(handle, observed) {
|
|
75
|
+
const origin = map.get(handle);
|
|
76
|
+
if (origin) origin.observed = observed;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wraps the driver that block models call to build frames and tables.
|
|
83
|
+
*
|
|
84
|
+
* Records the redacted join tree and its structural findings, then remembers
|
|
85
|
+
* which handle came from which join so later data calls can be attributed back
|
|
86
|
+
* to the definition that caused them.
|
|
87
|
+
*/
|
|
88
|
+
export function wrapModelDriver<D extends ModelDriverLike<unknown>>(
|
|
89
|
+
driver: D,
|
|
90
|
+
recorder: Recorder,
|
|
91
|
+
registry: HandleRegistry,
|
|
92
|
+
handleOf: (result: unknown) => string = defaultHandleOf,
|
|
93
|
+
): D {
|
|
94
|
+
const wrapped = {
|
|
95
|
+
createPFrame(def: readonly PColumn<unknown>[]) {
|
|
96
|
+
return record(recorder, registry, handleOf, "createPFrame", "PFrameDef", def, () =>
|
|
97
|
+
(driver.createPFrame as (d: unknown) => unknown)(def),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
createPTable(def: PTableDef<PColumn<unknown>>) {
|
|
101
|
+
return record(recorder, registry, handleOf, "createPTable", "PTableDef", def, () =>
|
|
102
|
+
(driver.createPTable as (d: unknown) => unknown)(def),
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
createPTableV2(def: PTableDefV2<PColumn<unknown>>) {
|
|
106
|
+
return record(recorder, registry, handleOf, "createPTableV2", "PTableDefV2", def, () =>
|
|
107
|
+
(driver.createPTableV2 as (d: unknown) => unknown)(def),
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
// The three creation methods are replaced and everything else is inherited,
|
|
112
|
+
// so the wrapper satisfies whichever driver interface the caller holds.
|
|
113
|
+
return Object.assign(Object.create(driver as object) as D, wrapped);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wraps the asynchronous data-access driver.
|
|
118
|
+
*
|
|
119
|
+
* Adds the observed table shape, the size of what crossed back into JavaScript,
|
|
120
|
+
* and the amplification between input and output rows — the empirical
|
|
121
|
+
* counterpart to the structural findings taken from the join tree.
|
|
122
|
+
*/
|
|
123
|
+
export function wrapDataDriver<D extends object>(
|
|
124
|
+
driver: D,
|
|
125
|
+
recorder: Recorder,
|
|
126
|
+
registry: HandleRegistry,
|
|
127
|
+
): D {
|
|
128
|
+
const source = driver as unknown as {
|
|
129
|
+
getShape(handle: string, ...rest: unknown[]): Promise<{ rows: number; columns: number }>;
|
|
130
|
+
getData(
|
|
131
|
+
handle: string,
|
|
132
|
+
columnIndices: number[],
|
|
133
|
+
range?: { offset: number; length: number },
|
|
134
|
+
...rest: unknown[]
|
|
135
|
+
): Promise<unknown[]>;
|
|
136
|
+
calculateTableData(handle: string, request: unknown, ...rest: unknown[]): Promise<unknown[]>;
|
|
137
|
+
getUniqueValues(
|
|
138
|
+
handle: string,
|
|
139
|
+
request: unknown,
|
|
140
|
+
...rest: unknown[]
|
|
141
|
+
): Promise<{ values?: { data?: unknown }; overflow?: boolean }>;
|
|
142
|
+
findColumns(
|
|
143
|
+
handle: string,
|
|
144
|
+
request: unknown,
|
|
145
|
+
...rest: unknown[]
|
|
146
|
+
): Promise<{ hits?: unknown[] }>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const wrapped = {
|
|
150
|
+
async getShape(handle: string, ...rest: unknown[]) {
|
|
151
|
+
const origin = registry.get(handle);
|
|
152
|
+
return await span(
|
|
153
|
+
recorder,
|
|
154
|
+
"getShape",
|
|
155
|
+
{ handle: shortHandle(handle), joinSeq: origin?.seq },
|
|
156
|
+
async () => {
|
|
157
|
+
const shape = await source.getShape(handle, ...rest);
|
|
158
|
+
registry.observe(handle, { rows: shape?.rows, columns: shape?.columns });
|
|
159
|
+
// How much this join amplified is judged in the analyzer, which can
|
|
160
|
+
// read the definition this handle came from without repeating the
|
|
161
|
+
// work here, on the hot path.
|
|
162
|
+
return { result: shape, detail: { rows: shape?.rows, columns: shape?.columns } };
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async getData(
|
|
168
|
+
handle: string,
|
|
169
|
+
columnIndices: number[],
|
|
170
|
+
range?: { offset: number; length: number },
|
|
171
|
+
...rest: unknown[]
|
|
172
|
+
) {
|
|
173
|
+
const origin = registry.get(handle);
|
|
174
|
+
return await span(
|
|
175
|
+
recorder,
|
|
176
|
+
"getData",
|
|
177
|
+
{
|
|
178
|
+
handle: shortHandle(handle),
|
|
179
|
+
joinSeq: origin?.seq,
|
|
180
|
+
columnCount: columnIndices?.length,
|
|
181
|
+
range: range ? { offset: range.offset, length: range.length } : null,
|
|
182
|
+
// A fetch with no range pulls the whole table into the JS heap, which
|
|
183
|
+
// on a large table is an out-of-memory condition by itself.
|
|
184
|
+
unbounded: !range,
|
|
185
|
+
tableRows: origin?.observed?.rows,
|
|
186
|
+
},
|
|
187
|
+
async () => {
|
|
188
|
+
const data = await source.getData(handle, columnIndices, range, ...rest);
|
|
189
|
+
return { result: data, detail: { returnedBytes: vectorsBytes(data) } };
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async calculateTableData(handle: string, request: unknown, ...rest: unknown[]) {
|
|
195
|
+
return await span(
|
|
196
|
+
recorder,
|
|
197
|
+
"calculateTableData",
|
|
198
|
+
{ handle: shortHandle(handle), def: digestDef("PTableDef", request) },
|
|
199
|
+
async () => {
|
|
200
|
+
const data = await source.calculateTableData(handle, request, ...rest);
|
|
201
|
+
const vectors = (data ?? []).map((column) => (column as { data?: unknown })?.data);
|
|
202
|
+
return {
|
|
203
|
+
result: data,
|
|
204
|
+
detail: { columns: data?.length, returnedBytes: vectorsBytes(vectors) },
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
// Both of these reach the engine as well, and a filter that matches most of
|
|
211
|
+
// a large axis is a plausible place to run out of memory. The request is
|
|
212
|
+
// recorded redacted: axis identity survives, filter values become hashes.
|
|
213
|
+
async getUniqueValues(handle: string, request: unknown, ...rest: unknown[]) {
|
|
214
|
+
return await span(
|
|
215
|
+
recorder,
|
|
216
|
+
"getUniqueValues",
|
|
217
|
+
{ handle: shortHandle(handle), request: redact(request).value },
|
|
218
|
+
async () => {
|
|
219
|
+
const response = await source.getUniqueValues(handle, request, ...rest);
|
|
220
|
+
return {
|
|
221
|
+
result: response,
|
|
222
|
+
detail: {
|
|
223
|
+
uniqueValues: vectorLength(response?.values),
|
|
224
|
+
overflow: response?.overflow,
|
|
225
|
+
returnedBytes: vectorsBytes([response?.values]),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
async findColumns(handle: string, request: unknown, ...rest: unknown[]) {
|
|
233
|
+
return await span(
|
|
234
|
+
recorder,
|
|
235
|
+
"findColumns",
|
|
236
|
+
{ handle: shortHandle(handle), request: redact(request).value },
|
|
237
|
+
async () => {
|
|
238
|
+
const response = await source.findColumns(handle, request, ...rest);
|
|
239
|
+
return { result: response, detail: { hits: response?.hits?.length } };
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return Object.assign(Object.create(driver) as D, wrapped);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Records one block model render.
|
|
250
|
+
*
|
|
251
|
+
* `getStats` exposes the middle layer's own sandbox accounting, whose
|
|
252
|
+
* serialisation byte counts show how much data the model moved across the
|
|
253
|
+
* QuickJS boundary — the model-layer memory cost that no driver call reports.
|
|
254
|
+
*/
|
|
255
|
+
export async function recordModelRender<T>(
|
|
256
|
+
recorder: Recorder | undefined,
|
|
257
|
+
info: RenderInfo,
|
|
258
|
+
fn: () => Promise<T>,
|
|
259
|
+
): Promise<T> {
|
|
260
|
+
if (!recorder) return await fn();
|
|
261
|
+
const { getStats, ...plain } = info;
|
|
262
|
+
const begin = recorder.event("render-begin", { ...plain, mem: recorder.memorySnapshot() });
|
|
263
|
+
const startedAt = performance.now();
|
|
264
|
+
try {
|
|
265
|
+
const result = await fn();
|
|
266
|
+
recorder.event("render-end", {
|
|
267
|
+
begin,
|
|
268
|
+
...plain,
|
|
269
|
+
ms: round(performance.now() - startedAt),
|
|
270
|
+
stats: getStats?.(),
|
|
271
|
+
mem: recorder.memorySnapshot(),
|
|
272
|
+
});
|
|
273
|
+
return result;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
recorder.event("render-error", {
|
|
276
|
+
begin,
|
|
277
|
+
...plain,
|
|
278
|
+
ms: round(performance.now() - startedAt),
|
|
279
|
+
error: describeError(error),
|
|
280
|
+
mem: recorder.memorySnapshot(),
|
|
281
|
+
});
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Synchronous variant, for a render that is not driven by a promise. */
|
|
287
|
+
export function recordModelRenderSync<T>(
|
|
288
|
+
recorder: Recorder | undefined,
|
|
289
|
+
info: RenderInfo,
|
|
290
|
+
fn: () => T,
|
|
291
|
+
): T {
|
|
292
|
+
if (!recorder) return fn();
|
|
293
|
+
// The identity is written once as its own record and referred to by block id
|
|
294
|
+
// thereafter; repeating it on every render would be the same four fields over
|
|
295
|
+
// and over in the log a crash has to fit into.
|
|
296
|
+
const { getStats, block: _b, blockVersion: _v, blockSource: _s, sdkVersion: _k, ...plain } = info;
|
|
297
|
+
announceBlock(recorder, info);
|
|
298
|
+
const begin = recorder.event("render-begin", { ...plain, mem: recorder.memorySnapshot() });
|
|
299
|
+
const startedAt = performance.now();
|
|
300
|
+
// Driver calls the model makes while this render runs belong to this block.
|
|
301
|
+
// Recorded here rather than inferred later: an inference has to survive log
|
|
302
|
+
// rotation and cannot see a call made after the render that caused it returned.
|
|
303
|
+
openRenders.push(info.blockId);
|
|
304
|
+
try {
|
|
305
|
+
const result = fn();
|
|
306
|
+
recorder.event("render-end", {
|
|
307
|
+
begin,
|
|
308
|
+
...plain,
|
|
309
|
+
ms: round(performance.now() - startedAt),
|
|
310
|
+
stats: getStats?.(),
|
|
311
|
+
mem: recorder.memorySnapshot(),
|
|
312
|
+
});
|
|
313
|
+
return result;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
recorder.event("render-error", {
|
|
316
|
+
begin,
|
|
317
|
+
...plain,
|
|
318
|
+
ms: round(performance.now() - startedAt),
|
|
319
|
+
error: describeError(error),
|
|
320
|
+
mem: recorder.memorySnapshot(),
|
|
321
|
+
});
|
|
322
|
+
throw error;
|
|
323
|
+
} finally {
|
|
324
|
+
openRenders.pop();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Internals
|
|
329
|
+
|
|
330
|
+
/** Blocks whose renders are open, innermost last. */
|
|
331
|
+
const openRenders: (string | undefined)[] = [];
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Writes what a block actually is, once per session.
|
|
335
|
+
*
|
|
336
|
+
* A block id is unique to a project, so on its own it names nothing a reader can
|
|
337
|
+
* open: the package, its version and the SDK it was built against are what point
|
|
338
|
+
* at the code that ran. Written as a sticky record, so a session long enough to
|
|
339
|
+
* rotate its log away from the first render does not lose the legend for every
|
|
340
|
+
* id in what survives.
|
|
341
|
+
*/
|
|
342
|
+
function announceBlock(recorder: Recorder, info: RenderInfo): void {
|
|
343
|
+
const { blockId, block, blockVersion, blockSource, sdkVersion } = info;
|
|
344
|
+
if (blockId === undefined || block === undefined) return;
|
|
345
|
+
const identity = `${blockId}\u0000${block}\u0000${blockVersion}\u0000${sdkVersion}`;
|
|
346
|
+
recorder.sticky(identity, "block", { blockId, block, blockVersion, blockSource, sdkVersion });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function record<R>(
|
|
350
|
+
recorder: Recorder,
|
|
351
|
+
registry: HandleRegistry,
|
|
352
|
+
handleOf: (result: unknown) => string,
|
|
353
|
+
op: string,
|
|
354
|
+
kind: DefKind,
|
|
355
|
+
def: unknown,
|
|
356
|
+
call: () => R,
|
|
357
|
+
): R {
|
|
358
|
+
let digest: DefDigest | { digestFailed: string };
|
|
359
|
+
try {
|
|
360
|
+
digest = digestDef(kind, def);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// Diagnostics must never be the reason a join fails to build.
|
|
363
|
+
digest = { digestFailed: describeError(error) };
|
|
364
|
+
}
|
|
365
|
+
// A creation call is synchronous but not free: it hands the definition to the
|
|
366
|
+
// native engine, which can allocate. It gets a begin/end pair like any other
|
|
367
|
+
// operation, so a death inside it is attributed to it and not to the render
|
|
368
|
+
// around it.
|
|
369
|
+
const seq = recorder.event(`${op}-begin`, {
|
|
370
|
+
def: digest,
|
|
371
|
+
blockId: openRenders.at(-1),
|
|
372
|
+
mem: recorder.memorySnapshot(),
|
|
373
|
+
});
|
|
374
|
+
const startedAt = performance.now();
|
|
375
|
+
|
|
376
|
+
let result: R;
|
|
377
|
+
try {
|
|
378
|
+
result = call();
|
|
379
|
+
} catch (error) {
|
|
380
|
+
recorder.event(`${op}-error`, {
|
|
381
|
+
begin: seq,
|
|
382
|
+
ms: round(performance.now() - startedAt),
|
|
383
|
+
error: describeError(error),
|
|
384
|
+
mem: recorder.memorySnapshot(),
|
|
385
|
+
});
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const handle = handleOf(result);
|
|
390
|
+
registry.put(handle, { seq, op });
|
|
391
|
+
recorder.event(`${op}-end`, {
|
|
392
|
+
begin: seq,
|
|
393
|
+
ms: round(performance.now() - startedAt),
|
|
394
|
+
handle: shortHandle(handle),
|
|
395
|
+
mem: recorder.memorySnapshot(),
|
|
396
|
+
});
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function span<T>(
|
|
401
|
+
recorder: Recorder,
|
|
402
|
+
op: string,
|
|
403
|
+
info: Record<string, unknown>,
|
|
404
|
+
fn: () => Promise<{ result: T; detail: Record<string, unknown> }>,
|
|
405
|
+
): Promise<T> {
|
|
406
|
+
const begin = recorder.event(`${op}-begin`, { ...info, mem: recorder.memorySnapshot() });
|
|
407
|
+
const startedAt = performance.now();
|
|
408
|
+
try {
|
|
409
|
+
const { result, detail } = await fn();
|
|
410
|
+
recorder.event(`${op}-end`, {
|
|
411
|
+
begin,
|
|
412
|
+
ms: round(performance.now() - startedAt),
|
|
413
|
+
...detail,
|
|
414
|
+
mem: recorder.memorySnapshot(),
|
|
415
|
+
});
|
|
416
|
+
return result;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
recorder.event(`${op}-error`, {
|
|
419
|
+
begin,
|
|
420
|
+
ms: round(performance.now() - startedAt),
|
|
421
|
+
error: describeError(error),
|
|
422
|
+
mem: recorder.memorySnapshot(),
|
|
423
|
+
});
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Accepts both a bare handle string and a pool entry wrapping one. */
|
|
429
|
+
function defaultHandleOf(result: unknown): string {
|
|
430
|
+
if (typeof result === "string") return result;
|
|
431
|
+
const key = (result as { key?: unknown } | null)?.key;
|
|
432
|
+
return typeof key === "string" ? key : String(result);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function vectorsBytes(vectors: unknown[]): number {
|
|
436
|
+
let bytes = 0;
|
|
437
|
+
for (const vector of vectors ?? []) {
|
|
438
|
+
const data = (vector as { data?: unknown; isNA?: { byteLength?: number } } | null)?.data;
|
|
439
|
+
if (!data) continue;
|
|
440
|
+
const byteLength = (data as { byteLength?: number }).byteLength;
|
|
441
|
+
if (typeof byteLength === "number") {
|
|
442
|
+
bytes += byteLength;
|
|
443
|
+
} else if (Array.isArray(data)) {
|
|
444
|
+
// String and Bytes columns arrive as plain arrays, and those are the ones
|
|
445
|
+
// that actually threaten the JS heap, so they are sampled not skipped.
|
|
446
|
+
bytes += sampledArrayBytes(data);
|
|
447
|
+
}
|
|
448
|
+
const isNA = (vector as { isNA?: { byteLength?: number } }).isNA;
|
|
449
|
+
if (typeof isNA?.byteLength === "number") bytes += isNA.byteLength;
|
|
450
|
+
}
|
|
451
|
+
return bytes;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function vectorLength(vector: unknown): number | undefined {
|
|
455
|
+
const data = (vector as { data?: { length?: number } } | undefined)?.data;
|
|
456
|
+
return typeof data?.length === "number" ? data.length : undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function sampledArrayBytes(data: unknown[]): number {
|
|
460
|
+
const sampleSize = Math.min(data.length, 32);
|
|
461
|
+
if (sampleSize === 0) return 0;
|
|
462
|
+
let sampled = 0;
|
|
463
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
464
|
+
const value = data[Math.floor((i * data.length) / sampleSize)];
|
|
465
|
+
sampled += typeof value === "string" ? value.length * 2 + 24 : 8;
|
|
466
|
+
}
|
|
467
|
+
return Math.round((sampled / sampleSize) * data.length);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function shortHandle(handle: string): string {
|
|
471
|
+
return typeof handle === "string" ? handle.slice(0, 24) : String(handle);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function describeError(error: unknown): string {
|
|
475
|
+
return String((error as { message?: unknown } | null)?.message ?? error);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function round(value: number): number {
|
|
479
|
+
return Math.round(value * 100) / 100;
|
|
480
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import type { MachineMemory } from "./events";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads where the machine's memory currently is.
|
|
7
|
+
*
|
|
8
|
+
* Costs about two milliseconds on macOS and one file read on Linux, which is why
|
|
9
|
+
* the sampler can afford it once a second. It never throws: a reading that cannot
|
|
10
|
+
* be taken says so and the sampler carries on, because a missing number must not
|
|
11
|
+
* cost the resident-size curve it accompanies.
|
|
12
|
+
*/
|
|
13
|
+
export function readMachineMemory(): MachineMemory {
|
|
14
|
+
try {
|
|
15
|
+
if (process.platform === "darwin") return readDarwin();
|
|
16
|
+
if (process.platform === "linux") return readLinux();
|
|
17
|
+
return { unavailable: `not implemented for ${process.platform}` };
|
|
18
|
+
} catch (error: unknown) {
|
|
19
|
+
return { unavailable: error instanceof Error ? error.message : String(error) };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Internals
|
|
24
|
+
|
|
25
|
+
function readDarwin(): MachineMemory {
|
|
26
|
+
const stat = execFileSync("vm_stat", { encoding: "utf8", timeout: 2000 });
|
|
27
|
+
// The page size is stated in the header and is 16 KiB on Apple silicon against
|
|
28
|
+
// 4 KiB elsewhere, so every count below is meaningless without reading it.
|
|
29
|
+
const pageSize = Number(/page size of (\d+) bytes/.exec(stat)?.[1] ?? 4096);
|
|
30
|
+
const pages = (label: string): number | undefined => {
|
|
31
|
+
const match = new RegExp(`${label}:\\s+(\\d+)\\.`).exec(stat);
|
|
32
|
+
return match ? Number(match[1]) * pageSize : undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const swap = execFileSync("sysctl", ["-n", "vm.swapusage"], { encoding: "utf8", timeout: 2000 });
|
|
36
|
+
const swapBytes = (label: string): number | undefined => {
|
|
37
|
+
const match = new RegExp(`${label} = ([\\d.]+)M`).exec(swap);
|
|
38
|
+
return match ? Number(match[1]) * 1024 * 1024 : undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
// "Stored" counts the memory before compression and is what went missing from
|
|
43
|
+
// a process's resident set; "occupied" is what it costs the machine now.
|
|
44
|
+
compressedStored: pages("Pages stored in compressor"),
|
|
45
|
+
compressedOccupied: pages("Pages occupied by compressor"),
|
|
46
|
+
swapUsed: swapBytes("used"),
|
|
47
|
+
swapTotal: swapBytes("total"),
|
|
48
|
+
anonymous: pages("Anonymous pages"),
|
|
49
|
+
fileBacked: pages("File-backed pages"),
|
|
50
|
+
wired: pages("Pages wired down"),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readLinux(): MachineMemory {
|
|
55
|
+
const info = fs.readFileSync("/proc/meminfo", "utf8");
|
|
56
|
+
const kb = (label: string): number | undefined => {
|
|
57
|
+
const match = new RegExp(`^${label}:\\s+(\\d+) kB`, "m").exec(info);
|
|
58
|
+
return match ? Number(match[1]) * 1024 : undefined;
|
|
59
|
+
};
|
|
60
|
+
const swapTotal = kb("SwapTotal");
|
|
61
|
+
const swapFree = kb("SwapFree");
|
|
62
|
+
return {
|
|
63
|
+
swapTotal,
|
|
64
|
+
swapUsed: swapTotal !== undefined && swapFree !== undefined ? swapTotal - swapFree : undefined,
|
|
65
|
+
anonymous: kb("AnonPages"),
|
|
66
|
+
fileBacked: kb("Cached"),
|
|
67
|
+
// Linux has no compressor of its own; zram, where present, reports as swap.
|
|
68
|
+
wired: kb("Unevictable"),
|
|
69
|
+
};
|
|
70
|
+
}
|