@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.
Files changed (58) hide show
  1. package/README.md +119 -0
  2. package/dist/data_summary.js +100 -0
  3. package/dist/data_summary.js.map +1 -0
  4. package/dist/digest.js +26 -0
  5. package/dist/digest.js.map +1 -0
  6. package/dist/events.d.ts +149 -0
  7. package/dist/events.d.ts.map +1 -0
  8. package/dist/events.js +13 -0
  9. package/dist/events.js.map +1 -0
  10. package/dist/host_sampler.d.ts +35 -0
  11. package/dist/host_sampler.d.ts.map +1 -0
  12. package/dist/host_sampler.js +54 -0
  13. package/dist/host_sampler.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.js +6 -0
  16. package/dist/instrument.d.ts +79 -0
  17. package/dist/instrument.d.ts.map +1 -0
  18. package/dist/instrument.js +305 -0
  19. package/dist/instrument.js.map +1 -0
  20. package/dist/machine_memory.js +68 -0
  21. package/dist/machine_memory.js.map +1 -0
  22. package/dist/recorder.d.ts +70 -0
  23. package/dist/recorder.d.ts.map +1 -0
  24. package/dist/recorder.js +278 -0
  25. package/dist/recorder.js.map +1 -0
  26. package/dist/redact.js +141 -0
  27. package/dist/redact.js.map +1 -0
  28. package/dist/sampler.d.ts +8 -0
  29. package/dist/sampler.d.ts.map +1 -0
  30. package/dist/sampler.js +33 -0
  31. package/dist/sampler.js.map +1 -0
  32. package/dist/sampler_thread.d.ts +1 -0
  33. package/dist/sampler_thread.js +53 -0
  34. package/dist/sampler_thread.js.map +1 -0
  35. package/dist/session.d.ts +40 -0
  36. package/dist/session.d.ts.map +1 -0
  37. package/dist/session.js +50 -0
  38. package/dist/session.js.map +1 -0
  39. package/dist/supervisor.d.ts +37 -0
  40. package/dist/supervisor.d.ts.map +1 -0
  41. package/dist/supervisor.js +138 -0
  42. package/dist/supervisor.js.map +1 -0
  43. package/package.json +43 -0
  44. package/src/data_summary.ts +163 -0
  45. package/src/digest.ts +36 -0
  46. package/src/events.ts +166 -0
  47. package/src/host_sampler.ts +83 -0
  48. package/src/index.ts +51 -0
  49. package/src/instrument.ts +480 -0
  50. package/src/machine_memory.ts +70 -0
  51. package/src/recorder.test.ts +334 -0
  52. package/src/recorder.ts +435 -0
  53. package/src/redact.test.ts +155 -0
  54. package/src/redact.ts +213 -0
  55. package/src/sampler.ts +40 -0
  56. package/src/sampler_thread.ts +60 -0
  57. package/src/session.ts +71 -0
  58. package/src/supervisor.ts +183 -0
@@ -0,0 +1,334 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { listSessions, openRecorder, type Recorder } from "./recorder";
6
+ import { DEATH_FILE_PREFIX } from "./events";
7
+ import { readCrashMarkers, writeCrashMarker } from "./supervisor";
8
+ import { createHandleRegistry, recordModelRenderSync, wrapModelDriver } from "./instrument";
9
+ import { startHostSampler } from "./host_sampler";
10
+
11
+ /**
12
+ * What the recorder must guarantee about the log it leaves behind.
13
+ *
14
+ * Asserted against the raw lines rather than through any reader, because the
15
+ * reader is not part of this package: a log is a file someone else will open,
16
+ * and these are the properties that file has to have on its own.
17
+ */
18
+
19
+ let dir: string;
20
+
21
+ beforeEach(() => {
22
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "crash-recorder-"));
23
+ });
24
+
25
+ afterEach(() => {
26
+ fs.rmSync(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("what a crash leaves behind", () => {
30
+ test("a session with no terminating record is reported as crashed", () => {
31
+ const recorder = openRecorder({ dir });
32
+ recorder.event("getShape-begin", { handle: "t1" });
33
+ // Deliberately not closed: this is what a killed process leaves.
34
+
35
+ const [session] = listSessions(dir);
36
+ expect(session.crashed).toBe(true);
37
+ expect(recordsOf(recorder).some((r) => r.type === "getShape-begin")).toBe(true);
38
+ });
39
+
40
+ test("a closed session is reported as clean", () => {
41
+ const recorder = openRecorder({ dir });
42
+ recorder.event("getShape-begin", { handle: "t1" });
43
+ recorder.close();
44
+
45
+ expect(listSessions(dir)[0].crashed).toBe(false);
46
+ });
47
+
48
+ test("records are readable even though the process never flushed", () => {
49
+ // Written with writeSync on an append descriptor, so a kill -9 between two
50
+ // records costs the records after it and nothing before.
51
+ const recorder = openRecorder({ dir });
52
+ for (let i = 0; i < 50; i++) recorder.event("mem-self", { mem: recorder.memorySnapshot() });
53
+ expect(recordsOf(recorder)).toHaveLength(51);
54
+ });
55
+ });
56
+
57
+ describe("rotation", () => {
58
+ test("the session header, the earliest memory reading and open begins are carried", () => {
59
+ const recorder = openRecorder({ dir, maxFileBytes: 1800, meta: { appVersion: "rot" } });
60
+ const begin = recorder.event("createPTable-begin", {
61
+ blockId: "block-7",
62
+ mem: recorder.memorySnapshot(),
63
+ });
64
+ rotateUntilEarliestSegmentLost(recorder);
65
+
66
+ const records = recordsOf(recorder);
67
+ // The header, or nothing in the surviving segment says which session it is.
68
+ expect(records.some((r) => r.type === "session" && r.continuation === true)).toBe(true);
69
+ // The baseline, or growth would be measured from mid-session.
70
+ expect(records.some((r) => r.type === "mem-baseline")).toBe(true);
71
+ // The open begin, under its original sequence number, or the operation that
72
+ // was running at the moment of death disappears from the log entirely.
73
+ const carried = records.find((r) => r.type === "createPTable-begin" && r.carriedForward);
74
+ expect(carried?.seq).toBe(begin);
75
+ expect(carried?.blockId).toBe("block-7");
76
+ });
77
+
78
+ test("a begin that has ended is not carried", () => {
79
+ const recorder = openRecorder({ dir, maxFileBytes: 1800 });
80
+ const begin = recorder.event("getShape-begin", { handle: "t1" });
81
+ recorder.event("getShape-end", { begin });
82
+ rotateUntilEarliestSegmentLost(recorder);
83
+
84
+ expect(recordsOf(recorder).some((r) => r.type === "getShape-begin")).toBe(false);
85
+ });
86
+
87
+ test("a sticky record outlives the segment it was written in", () => {
88
+ const recorder = openRecorder({ dir, maxFileBytes: 1800 });
89
+ recorder.sticky("b1", "block", { blockId: "b1", block: "org:name", blockVersion: "2.4.1" });
90
+ rotateUntilEarliestSegmentLost(recorder);
91
+
92
+ // Without this a long session loses the legend for every block id in what
93
+ // survives, and the surviving records name code nobody can find.
94
+ const block = recordsOf(recorder).find((r) => r.type === "block");
95
+ expect(block?.block).toBe("org:name");
96
+ expect(block?.blockVersion).toBe("2.4.1");
97
+ });
98
+
99
+ test("a sticky record is written once however often it is offered", () => {
100
+ const recorder = openRecorder({ dir });
101
+ for (let i = 0; i < 5; i++) recorder.sticky("b1", "block", { blockId: "b1" });
102
+
103
+ expect(recordsOf(recorder).filter((r) => r.type === "block")).toHaveLength(1);
104
+ });
105
+ });
106
+
107
+ describe("what a record says about the code that made it", () => {
108
+ test("a driver call made inside a render carries that render's block", () => {
109
+ const recorder = openRecorder({ dir });
110
+ const driver = wrapModelDriver(fakeModelDriver(), recorder, createHandleRegistry());
111
+ recordModelRenderSync(recorder, { blockId: "b1", block: "org:name" }, () => {
112
+ driver.createPTable({ src: { type: "inner", entries: [] } });
113
+ });
114
+
115
+ const created = recordsOf(recorder).find((r) => r.type === "createPTable-begin");
116
+ expect(created?.blockId).toBe("b1");
117
+ });
118
+
119
+ test("a driver call made outside every render claims no block", () => {
120
+ const recorder = openRecorder({ dir });
121
+ const driver = wrapModelDriver(fakeModelDriver(), recorder, createHandleRegistry());
122
+ driver.createPTable({ src: { type: "inner", entries: [] } });
123
+
124
+ const created = recordsOf(recorder).find((r) => r.type === "createPTable-begin");
125
+ expect(created?.blockId).toBeUndefined();
126
+ });
127
+
128
+ test("a definition is recorded without the values it was built from", () => {
129
+ const recorder = openRecorder({ dir });
130
+ const driver = wrapModelDriver(fakeModelDriver(), recorder, createHandleRegistry());
131
+ const secret = "PATIENT-0007-CDR3";
132
+ driver.createPTable({
133
+ src: {
134
+ type: "inner",
135
+ entries: [
136
+ {
137
+ type: "column",
138
+ column: {
139
+ id: "c",
140
+ spec: { kind: "PColumn", name: "x", valueType: "String", axesSpec: [] },
141
+ data: [{ key: [secret], val: secret }],
142
+ },
143
+ },
144
+ ],
145
+ },
146
+ });
147
+
148
+ expect(fs.readFileSync(recorder.file, "utf8")).not.toContain(secret);
149
+ });
150
+ });
151
+
152
+ describe("what a definition records about its keys", () => {
153
+ test("distinct tuples are counted, not inferred from the axes", () => {
154
+ const recorder = openRecorder({ dir });
155
+ const driver = wrapModelDriver(fakeModelDriver(), recorder, createHandleRegistry());
156
+ // Two axes that vary together: four records, two values on each axis, but
157
+ // only two key tuples. Multiplying the per-axis counts would say four.
158
+ driver.createPTable(
159
+ columnDef([
160
+ [1, "a"],
161
+ [1, "a"],
162
+ [2, "b"],
163
+ [2, "b"],
164
+ ]),
165
+ );
166
+
167
+ const def = recordsOf(recorder).find((r) => r.type === "createPTable-begin")?.def;
168
+ const data = def?.def?.src?.entries?.[0]?.column?.data;
169
+ expect(data.axisCardinality).toEqual([2, 2]);
170
+ expect(data.distinctKeys).toBe(2);
171
+ });
172
+
173
+ test("counting is declined rather than approximated past the cap", () => {
174
+ const recorder = openRecorder({ dir });
175
+ const driver = wrapModelDriver(fakeModelDriver(), recorder, createHandleRegistry());
176
+ driver.createPTable(
177
+ columnDef(Array.from({ length: 100_001 }, (_, i) => [i, String(i)] as [number, string])),
178
+ );
179
+
180
+ const def = recordsOf(recorder).find((r) => r.type === "createPTable-begin")?.def;
181
+ const data = def?.def?.src?.entries?.[0]?.column?.data;
182
+ // A number that is present is always true; here none is, and the log says so.
183
+ expect(data.axisCardinality).toBeUndefined();
184
+ expect(data.distinctKeys).toBeUndefined();
185
+ expect(data.axisCardinalityUncounted).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe("host readings", () => {
190
+ test("attribution is written beside the session, not into it", async () => {
191
+ const recorder = openRecorder({ dir });
192
+ const sampler = startHostSampler({
193
+ dir,
194
+ sessionId: recorder.sessionId,
195
+ intervalMs: 5,
196
+ read: () => ({ private: 42 * 1024 ** 3, swapUsed: 1024 ** 3 }),
197
+ });
198
+ await new Promise((resolve) => setTimeout(resolve, 40));
199
+ sampler.stop();
200
+
201
+ // A separate file: the session log belongs to another thread, with its own
202
+ // descriptor and sequence, and a second writer would corrupt both.
203
+ expect(sampler.file).not.toBe(recorder.file);
204
+ expect(path.basename(sampler.file)).toBe(`host-${recorder.sessionId}.ndjson`);
205
+ const records = fs
206
+ .readFileSync(sampler.file, "utf8")
207
+ .split("\n")
208
+ .filter(Boolean)
209
+ .map((l) => JSON.parse(l) as Record<string, any>);
210
+ expect(records.length).toBeGreaterThan(0);
211
+ expect(records[0].type).toBe("mem-host");
212
+ expect(records[0].private).toBe(42 * 1024 ** 3);
213
+ });
214
+
215
+ test("a reading that throws costs the reading, not the application", async () => {
216
+ const recorder = openRecorder({ dir });
217
+ const sampler = startHostSampler({
218
+ dir,
219
+ sessionId: recorder.sessionId,
220
+ intervalMs: 5,
221
+ read: () => {
222
+ throw new Error("no such counter on this platform");
223
+ },
224
+ });
225
+ await new Promise((resolve) => setTimeout(resolve, 30));
226
+ sampler.stop();
227
+
228
+ expect(fs.readFileSync(sampler.file, "utf8")).toBe("");
229
+ });
230
+ });
231
+
232
+ describe("crash markers", () => {
233
+ test("a marker names the session the parent assigned, and carries memory", () => {
234
+ const recorder = openRecorder({ dir });
235
+ writeCrashMarker(dir, { reason: "worker-exit", code: 1, sessionId: recorder.sessionId });
236
+
237
+ const [marker] = readCrashMarkers(dir);
238
+ expect(marker.sessionId).toBe(recorder.sessionId);
239
+ // On its own an exit code reads like an ordinary failure; the reading taken
240
+ // beside it is what says whether memory was the reason.
241
+ expect(marker.memoryAtDeath?.rss).toBeGreaterThan(0);
242
+ expect(marker.memoryAtDeath?.totalMemory).toBeGreaterThan(0);
243
+ });
244
+
245
+ test("a marker with no assigned session claims none as its own", () => {
246
+ openRecorder({ dir });
247
+ writeCrashMarker(dir, { reason: "killed-by-os" });
248
+
249
+ const [marker] = readCrashMarkers(dir);
250
+ // Guessing is offered as advisory only: naming the wrong session would both
251
+ // misattribute the death and stop the right one from claiming it.
252
+ expect(marker.sessionId).toBeUndefined();
253
+ expect(marker.guessedSessionId).toBeDefined();
254
+ });
255
+
256
+ test("an unparseable marker is skipped rather than failing the rest", () => {
257
+ writeCrashMarker(dir, { reason: "worker-exit" });
258
+ // Named like a marker, so it reaches the parse rather than being filtered
259
+ // out by name — which is the path this test exists to cover.
260
+ fs.writeFileSync(path.join(dir, `${DEATH_FILE_PREFIX}-999.ndjson`), "{ not json\n");
261
+
262
+ expect(readCrashMarkers(dir)).toHaveLength(1);
263
+ });
264
+
265
+ test("a file that is not a marker is passed over without being read", () => {
266
+ writeCrashMarker(dir, { reason: "worker-exit" });
267
+ fs.writeFileSync(path.join(dir, "crash-999.ndjson"), "{ not json\n");
268
+
269
+ // Logs written under an earlier name share the directory; they are not
270
+ // markers now, and failing to parse one would be reading the wrong file.
271
+ expect(readCrashMarkers(dir)).toHaveLength(1);
272
+ });
273
+ });
274
+
275
+ // Internals
276
+
277
+ /** One column whose records carry the given two-part keys. */
278
+ function columnDef(keys: [number, string][]): unknown {
279
+ return {
280
+ src: {
281
+ type: "inner",
282
+ entries: [
283
+ {
284
+ type: "column",
285
+ column: {
286
+ id: "c",
287
+ spec: {
288
+ kind: "PColumn",
289
+ name: "x",
290
+ valueType: "Int",
291
+ axesSpec: [
292
+ { type: "Int", name: "a" },
293
+ { type: "String", name: "b" },
294
+ ],
295
+ },
296
+ data: keys.map((key, i) => ({ key, val: i })),
297
+ },
298
+ },
299
+ ],
300
+ },
301
+ partitionFilters: [],
302
+ filters: [],
303
+ sorting: [],
304
+ };
305
+ }
306
+
307
+ /** A driver that returns handles and does nothing, so only the recording shows. */
308
+ function fakeModelDriver() {
309
+ return {
310
+ createPFrame: (_def: unknown) => "f",
311
+ createPTable: (_def: unknown) => "t",
312
+ createPTableV2: (_def: unknown) => "t2",
313
+ };
314
+ }
315
+
316
+ function recordsOf(recorder: Recorder): Record<string, any>[] {
317
+ return fs
318
+ .readFileSync(recorder.file, "utf8")
319
+ .split("\n")
320
+ .filter(Boolean)
321
+ .map((line) => JSON.parse(line) as Record<string, any>);
322
+ }
323
+
324
+ /** Writes until the segment holding the original records has been discarded. */
325
+ function rotateUntilEarliestSegmentLost(recorder: Recorder): void {
326
+ const parked = `${recorder.file}.1`;
327
+ for (let guard = 0; guard < 20_000; guard++) {
328
+ recorder.event("mem-self", { mem: recorder.memorySnapshot() });
329
+ if (fs.existsSync(parked) && fs.readFileSync(parked, "utf8").includes('"continuation":true')) {
330
+ return;
331
+ }
332
+ }
333
+ throw new Error("log did not rotate twice");
334
+ }