@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,50 @@
1
+ import { openRecorder, startSelfSampler } from "./recorder.js";
2
+ import { startMemorySampler } from "./sampler.js";
3
+ import { createHandleRegistry } from "./instrument.js";
4
+ //#region src/session.ts
5
+ /** Environment variable naming the directory crash logs are written to. */
6
+ 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
+ const CRASH_SESSION_ENV = "MI_CRASH_RECORDER_SESSION";
13
+ /**
14
+ * Opens a recording session, or returns undefined when recording is not enabled.
15
+ *
16
+ * Recording is opt-in for now: it appends synchronously on every recorded
17
+ * operation, and that cost has not been measured against a real project, so it
18
+ * is switched on by pointing {@link CRASH_DIR_ENV} at a directory rather than
19
+ * being on by default.
20
+ */
21
+ function openRecordingSession(options = {}) {
22
+ const dir = options.dir ?? process.env["MI_CRASH_RECORDER_DIR"];
23
+ if (!dir) return void 0;
24
+ const recorder = openRecorder({
25
+ dir,
26
+ role: options.role,
27
+ meta: options.meta,
28
+ sessionId: options.sessionId ?? process.env["MI_CRASH_RECORDER_SESSION"] ?? void 0
29
+ });
30
+ const sampler = startMemorySampler({
31
+ dir,
32
+ sessionId: recorder.sessionId,
33
+ intervalMs: options.samplerIntervalMs
34
+ });
35
+ const stopSelfSampler = startSelfSampler(recorder, options.selfSamplerIntervalMs);
36
+ return {
37
+ recorder,
38
+ sampler,
39
+ registry: createHandleRegistry(),
40
+ close(reason) {
41
+ stopSelfSampler();
42
+ sampler.stop();
43
+ recorder.close(reason);
44
+ }
45
+ };
46
+ }
47
+ //#endregion
48
+ export { CRASH_DIR_ENV, CRASH_SESSION_ENV, openRecordingSession };
49
+
50
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","names":[],"sources":["../src/session.ts"],"sourcesContent":["import { openRecorder, startSelfSampler, type Recorder } from \"./recorder\";\nimport { startMemorySampler, type MemorySampler } from \"./sampler\";\nimport { createHandleRegistry, type HandleRegistry } from \"./instrument\";\n\n/** Environment variable naming the directory crash logs are written to. */\nexport const CRASH_DIR_ENV = \"MI_CRASH_RECORDER_DIR\";\n\n/**\n * Environment variable carrying the session id a supervising parent assigned.\n * Set it alongside {@link CRASH_DIR_ENV} when spawning the worker and pass the\n * same id to `superviseWorker`.\n */\nexport const CRASH_SESSION_ENV = \"MI_CRASH_RECORDER_SESSION\";\n\nexport type RecordingSessionOptions = {\n /** Overrides the directory from the environment. */\n dir?: string;\n /** Overrides the session id from the environment. */\n sessionId?: string;\n role?: string;\n meta?: Record<string, unknown>;\n samplerIntervalMs?: number;\n selfSamplerIntervalMs?: number;\n};\n\nexport type RecordingSession = {\n readonly recorder: Recorder;\n readonly sampler: MemorySampler;\n /** Shared so create calls and later data calls agree on handle identity. */\n readonly registry: HandleRegistry;\n close(reason?: string): void;\n};\n\n/**\n * Opens a recording session, or returns undefined when recording is not enabled.\n *\n * Recording is opt-in for now: it appends synchronously on every recorded\n * operation, and that cost has not been measured against a real project, so it\n * is switched on by pointing {@link CRASH_DIR_ENV} at a directory rather than\n * being on by default.\n */\nexport function openRecordingSession(\n options: RecordingSessionOptions = {},\n): RecordingSession | undefined {\n const dir = options.dir ?? process.env[CRASH_DIR_ENV];\n if (!dir) return undefined;\n\n const recorder = openRecorder({\n dir,\n role: options.role,\n meta: options.meta,\n sessionId: options.sessionId ?? process.env[CRASH_SESSION_ENV] ?? undefined,\n });\n const sampler = startMemorySampler({\n dir,\n sessionId: recorder.sessionId,\n intervalMs: options.samplerIntervalMs,\n });\n const stopSelfSampler = startSelfSampler(recorder, options.selfSamplerIntervalMs);\n\n return {\n recorder,\n sampler,\n registry: createHandleRegistry(),\n close(reason) {\n stopSelfSampler();\n sampler.stop();\n recorder.close(reason);\n },\n };\n}\n"],"mappings":";;;;;AAKA,MAAa,gBAAgB;;;;;;AAO7B,MAAa,oBAAoB;;;;;;;;;AA6BjC,SAAgB,qBACd,UAAmC,CAAC,GACN;CAC9B,MAAM,MAAM,QAAQ,OAAO,QAAQ,IAAA;CACnC,IAAI,CAAC,KAAK,OAAO,KAAA;CAEjB,MAAM,WAAW,aAAa;EAC5B;EACA,MAAM,QAAQ;EACd,MAAM,QAAQ;EACd,WAAW,QAAQ,aAAa,QAAQ,IAAA,gCAA0B,KAAA;CACpE,CAAC;CACD,MAAM,UAAU,mBAAmB;EACjC;EACA,WAAW,SAAS;EACpB,YAAY,QAAQ;CACtB,CAAC;CACD,MAAM,kBAAkB,iBAAiB,UAAU,QAAQ,qBAAqB;CAEhF,OAAO;EACL;EACA;EACA,UAAU,qBAAqB;EAC/B,MAAM,QAAQ;GACZ,gBAAgB;GAChB,QAAQ,KAAK;GACb,SAAS,MAAM,MAAM;EACvB;CACF;AACF"}
@@ -0,0 +1,37 @@
1
+ import { CrashMarker } from "./events.js";
2
+ //#region src/supervisor.d.ts
3
+ export type SuperviseOptions = {
4
+ /**
5
+ * The session id handed to the worker at spawn (see `CRASH_SESSION_ENV`).
6
+ * With it the marker names the dying session with certainty. Without it the
7
+ * analyzer has to attribute the marker by timing, and will decline to
8
+ * attribute it at all when more than one session looks dead.
9
+ */
10
+ sessionId?: string;
11
+ onCrash?: (info: {
12
+ kind: "error" | "exit";
13
+ markerFile: string;
14
+ error?: unknown;
15
+ code?: number;
16
+ }) => void;
17
+ };
18
+ /** Minimal view of a worker, so callers are not forced to import worker_threads. */
19
+ export type SupervisedWorker = {
20
+ on(event: "error", listener: (error: Error) => void): unknown;
21
+ on(event: "exit", listener: (code: number) => void): unknown;
22
+ };
23
+ /** Crash markers in a directory, oldest first. */
24
+ export declare function readCrashMarkers(dir: string): CrashMarker[];
25
+ /**
26
+ * Attaches crash recording to a middle-layer worker thread.
27
+ *
28
+ * A worker whose isolate exhausts its heap dies alone and the parent receives
29
+ * `ERR_WORKER_OUT_OF_MEMORY`, with or without `resourceLimits`. What
30
+ * `resourceLimits.maxOldGenerationSizeMb` adds is a chosen ceiling: V8's default
31
+ * is several gigabytes, so on a small machine the OS can run out of memory and
32
+ * kill the whole process before V8 ever reports the worker's heap as full — and
33
+ * then there is no parent left to write anything.
34
+ */
35
+ export declare function superviseWorker(worker: SupervisedWorker, dir: string, options?: SuperviseOptions): void;
36
+ //#endregion
37
+ //# sourceMappingURL=supervisor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervisor.d.ts","names":[],"sources":["../src/supervisor.ts"],"mappings":";;YAgBY;;;;;;;EAOV;EACA,WAAW;IACT;IACA;IACA;IACA;;;;YAKQ;EACV,GAAG,gBAAgB,WAAW,OAAO;EACrC,GAAG,eAAe,WAAW;;;wBAwCf,iBAAiB,cAAc;;;;;;;;;;;wBA+B/B,gBACd,QAAQ,kBACR,aACA,UAAS"}
@@ -0,0 +1,138 @@
1
+ import { DEATH_FILE_PREFIX } from "./events.js";
2
+ import { listSessions, sessionIdFromFile } from "./recorder.js";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ //#region src/supervisor.ts
7
+ /**
8
+ * Records an abnormal end observed from outside the dying thread.
9
+ *
10
+ * A thread that runs out of heap cannot describe its own death: the last reading
11
+ * it wrote predates the blow-up, and when the blow-up is synchronous no sampler
12
+ * tick of its own lands either. The parent is the only place where the cause is
13
+ * known rather than inferred — Node reports `ERR_WORKER_OUT_OF_MEMORY` to it —
14
+ * so the parent writes the verdict down on the dead thread's behalf.
15
+ */
16
+ function writeCrashMarker(dir, input = {}) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ const error = input.error;
19
+ const marker = {
20
+ type: "external-crash",
21
+ wall: Date.now(),
22
+ sessionId: input.sessionId,
23
+ guessedSessionId: input.sessionId === void 0 ? newestOpenSessionId(dir) : void 0,
24
+ reason: input.reason ?? classifyReason(input),
25
+ errorCode: error?.code,
26
+ errorName: error?.name,
27
+ message: truncate(String(error?.message ?? input.error ?? ""), 2e3),
28
+ exitCode: input.code,
29
+ signal: input.signal,
30
+ stderrTail: truncate(input.stderrTail ?? "", 4e3),
31
+ memoryAtDeath: memoryNow()
32
+ };
33
+ const file = path.join(dir, `${DEATH_FILE_PREFIX}-${marker.wall}.ndjson`);
34
+ fs.writeFileSync(file, `${JSON.stringify(marker)}\n`);
35
+ return file;
36
+ }
37
+ /** Crash markers in a directory, oldest first. */
38
+ function readCrashMarkers(dir) {
39
+ let names;
40
+ try {
41
+ names = fs.readdirSync(dir);
42
+ } catch {
43
+ return [];
44
+ }
45
+ const markers = [];
46
+ for (const name of names) {
47
+ if (!name.startsWith(`death-`) || !name.endsWith(".ndjson")) continue;
48
+ try {
49
+ const first = fs.readFileSync(path.join(dir, name), "utf8").split("\n")[0];
50
+ markers.push(JSON.parse(first));
51
+ } catch {}
52
+ }
53
+ return markers.sort((lhs, rhs) => lhs.wall - rhs.wall);
54
+ }
55
+ /**
56
+ * Attaches crash recording to a middle-layer worker thread.
57
+ *
58
+ * A worker whose isolate exhausts its heap dies alone and the parent receives
59
+ * `ERR_WORKER_OUT_OF_MEMORY`, with or without `resourceLimits`. What
60
+ * `resourceLimits.maxOldGenerationSizeMb` adds is a chosen ceiling: V8's default
61
+ * is several gigabytes, so on a small machine the OS can run out of memory and
62
+ * kill the whole process before V8 ever reports the worker's heap as full — and
63
+ * then there is no parent left to write anything.
64
+ */
65
+ function superviseWorker(worker, dir, options = {}) {
66
+ let recorded = false;
67
+ worker.on("error", (error) => {
68
+ recorded = true;
69
+ const markerFile = writeCrashMarker(dir, {
70
+ error,
71
+ sessionId: options.sessionId
72
+ });
73
+ options.onCrash?.({
74
+ kind: "error",
75
+ error,
76
+ markerFile
77
+ });
78
+ });
79
+ worker.on("exit", (code) => {
80
+ if (code === 0 || recorded) return;
81
+ const markerFile = writeCrashMarker(dir, {
82
+ reason: "worker-exit",
83
+ code,
84
+ sessionId: options.sessionId
85
+ });
86
+ options.onCrash?.({
87
+ kind: "exit",
88
+ code,
89
+ markerFile
90
+ });
91
+ });
92
+ }
93
+ /**
94
+ * The parent's view of memory at the moment it saw the death.
95
+ *
96
+ * The dying thread cannot take this reading, and the sampler's last one predates
97
+ * the end by up to its interval. Taken here it is contemporaneous with the exit
98
+ * code it sits beside, which is what stops an exhausted machine from reading as
99
+ * an ordinary failure.
100
+ *
101
+ * Every reading here is a syscall. The machine's compressor and swap totals are
102
+ * deliberately not among them: on macOS they cost a subprocess, and this runs on
103
+ * the parent's event loop inside the worker's error handler, before the marker
104
+ * is written and before the caller learns of the death. A fork is exactly what
105
+ * becomes slow or impossible on the exhausted machine this code exists for, so
106
+ * the fuller picture is left to the sampler, whose last reading is at most one
107
+ * interval old and sits in the same bundle.
108
+ */
109
+ function memoryNow() {
110
+ try {
111
+ return {
112
+ rss: process.memoryUsage.rss(),
113
+ maxRss: process.resourceUsage().maxRSS * 1024,
114
+ freeMemory: os.freemem(),
115
+ totalMemory: os.totalmem()
116
+ };
117
+ } catch {
118
+ return;
119
+ }
120
+ }
121
+ function newestOpenSessionId(dir) {
122
+ const open = listSessions(dir).find((session) => session.crashed);
123
+ return open ? sessionIdFromFile(open.file) : void 0;
124
+ }
125
+ function classifyReason({ error, code, signal }) {
126
+ if (error?.code === "ERR_WORKER_OUT_OF_MEMORY") return "js-heap-out-of-memory";
127
+ if (signal === "SIGKILL") return "killed-by-os";
128
+ if (signal === "SIGABRT" || code === 134) return "abort-or-fatal-allocation-failure";
129
+ if (typeof code === "number" && code !== 0) return "nonzero-exit";
130
+ return "unknown";
131
+ }
132
+ function truncate(value, limit) {
133
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
134
+ }
135
+ //#endregion
136
+ export { readCrashMarkers, superviseWorker, writeCrashMarker };
137
+
138
+ //# sourceMappingURL=supervisor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervisor.js","names":[],"sources":["../src/supervisor.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { DEATH_FILE_PREFIX, type CrashMarker, type CrashReason } from \"./events\";\nimport { listSessions, sessionIdFromFile } from \"./recorder\";\n\ntype CrashMarkerInput = {\n /** Session id the parent assigned to the worker. Omitted, the marker carries no identity. */\n sessionId?: string;\n reason?: CrashReason;\n error?: (Error & { code?: string }) | unknown;\n code?: number;\n signal?: string;\n stderrTail?: string;\n};\n\nexport type SuperviseOptions = {\n /**\n * The session id handed to the worker at spawn (see `CRASH_SESSION_ENV`).\n * With it the marker names the dying session with certainty. Without it the\n * analyzer has to attribute the marker by timing, and will decline to\n * attribute it at all when more than one session looks dead.\n */\n sessionId?: string;\n onCrash?: (info: {\n kind: \"error\" | \"exit\";\n markerFile: string;\n error?: unknown;\n code?: number;\n }) => void;\n};\n\n/** Minimal view of a worker, so callers are not forced to import worker_threads. */\nexport type SupervisedWorker = {\n on(event: \"error\", listener: (error: Error) => void): unknown;\n on(event: \"exit\", listener: (code: number) => void): unknown;\n};\n\n/**\n * Records an abnormal end observed from outside the dying thread.\n *\n * A thread that runs out of heap cannot describe its own death: the last reading\n * it wrote predates the blow-up, and when the blow-up is synchronous no sampler\n * tick of its own lands either. The parent is the only place where the cause is\n * known rather than inferred — Node reports `ERR_WORKER_OUT_OF_MEMORY` to it —\n * so the parent writes the verdict down on the dead thread's behalf.\n */\nexport function writeCrashMarker(dir: string, input: CrashMarkerInput = {}): string {\n fs.mkdirSync(dir, { recursive: true });\n const error = input.error as (Error & { code?: string }) | undefined;\n // Only an id the parent handed to the worker is certain, and only a certain\n // id goes in `sessionId`. Reading the newest open crash log names whichever\n // session wrote last, which a concurrent live session makes wrong; recorded\n // as identity that would misattribute the death and, worse, stop the session\n // that actually died from claiming the marker. So it is advisory only.\n const marker: CrashMarker = {\n type: \"external-crash\",\n wall: Date.now(),\n sessionId: input.sessionId,\n guessedSessionId: input.sessionId === undefined ? newestOpenSessionId(dir) : undefined,\n reason: input.reason ?? classifyReason(input),\n errorCode: error?.code,\n errorName: error?.name,\n message: truncate(String(error?.message ?? input.error ?? \"\"), 2000),\n exitCode: input.code,\n signal: input.signal,\n stderrTail: truncate(input.stderrTail ?? \"\", 4000),\n memoryAtDeath: memoryNow(),\n };\n const file = path.join(dir, `${DEATH_FILE_PREFIX}-${marker.wall}.ndjson`);\n fs.writeFileSync(file, `${JSON.stringify(marker)}\\n`);\n return file;\n}\n\n/** Crash markers in a directory, oldest first. */\nexport function readCrashMarkers(dir: string): CrashMarker[] {\n let names: string[];\n try {\n names = fs.readdirSync(dir);\n } catch {\n return [];\n }\n const markers: CrashMarker[] = [];\n for (const name of names) {\n if (!name.startsWith(`${DEATH_FILE_PREFIX}-`) || !name.endsWith(\".ndjson\")) continue;\n try {\n const first = fs.readFileSync(path.join(dir, name), \"utf8\").split(\"\\n\")[0];\n markers.push(JSON.parse(first) as CrashMarker);\n } catch {\n // A marker that cannot be parsed is skipped; it is one line of evidence,\n // not the report.\n }\n }\n return markers.sort((lhs, rhs) => lhs.wall - rhs.wall);\n}\n\n/**\n * Attaches crash recording to a middle-layer worker thread.\n *\n * A worker whose isolate exhausts its heap dies alone and the parent receives\n * `ERR_WORKER_OUT_OF_MEMORY`, with or without `resourceLimits`. What\n * `resourceLimits.maxOldGenerationSizeMb` adds is a chosen ceiling: V8's default\n * is several gigabytes, so on a small machine the OS can run out of memory and\n * kill the whole process before V8 ever reports the worker's heap as full — and\n * then there is no parent left to write anything.\n */\nexport function superviseWorker(\n worker: SupervisedWorker,\n dir: string,\n options: SuperviseOptions = {},\n): void {\n // One death fires `error` and then `exit`. Only `error` carries the cause, so\n // a later `exit` must not overwrite it with a bare exit code.\n let recorded = false;\n worker.on(\"error\", (error: Error) => {\n recorded = true;\n const markerFile = writeCrashMarker(dir, { error, sessionId: options.sessionId });\n options.onCrash?.({ kind: \"error\", error, markerFile });\n });\n worker.on(\"exit\", (code: number) => {\n if (code === 0 || recorded) return;\n const markerFile = writeCrashMarker(dir, {\n reason: \"worker-exit\",\n code,\n sessionId: options.sessionId,\n });\n options.onCrash?.({ kind: \"exit\", code, markerFile });\n });\n}\n\n// Internals\n\n/**\n * The parent's view of memory at the moment it saw the death.\n *\n * The dying thread cannot take this reading, and the sampler's last one predates\n * the end by up to its interval. Taken here it is contemporaneous with the exit\n * code it sits beside, which is what stops an exhausted machine from reading as\n * an ordinary failure.\n *\n * Every reading here is a syscall. The machine's compressor and swap totals are\n * deliberately not among them: on macOS they cost a subprocess, and this runs on\n * the parent's event loop inside the worker's error handler, before the marker\n * is written and before the caller learns of the death. A fork is exactly what\n * becomes slow or impossible on the exhausted machine this code exists for, so\n * the fuller picture is left to the sampler, whose last reading is at most one\n * interval old and sits in the same bundle.\n */\nfunction memoryNow(): CrashMarker[\"memoryAtDeath\"] {\n try {\n return {\n rss: process.memoryUsage.rss(),\n maxRss: process.resourceUsage().maxRSS * 1024,\n freeMemory: os.freemem(),\n totalMemory: os.totalmem(),\n };\n } catch {\n // A marker without memory is still a marker; failing to take the reading\n // must never cost the record of the death itself.\n return undefined;\n }\n}\n\n// Advisory only, for a human reading a directory by hand: the dying session has\n// no terminating record, so among the sessions that look dead this names the one\n// that wrote last. Never used as identity — see `CrashMarker.guessedSessionId`.\nfunction newestOpenSessionId(dir: string): string | undefined {\n const open = listSessions(dir).find((session) => session.crashed);\n return open ? sessionIdFromFile(open.file) : undefined;\n}\n\nfunction classifyReason({ error, code, signal }: CrashMarkerInput): CrashReason {\n const errorCode = (error as { code?: string } | undefined)?.code;\n if (errorCode === \"ERR_WORKER_OUT_OF_MEMORY\") return \"js-heap-out-of-memory\";\n if (signal === \"SIGKILL\") return \"killed-by-os\";\n if (signal === \"SIGABRT\" || code === 134) return \"abort-or-fatal-allocation-failure\";\n if (typeof code === \"number\" && code !== 0) return \"nonzero-exit\";\n return \"unknown\";\n}\n\nfunction truncate(value: string, limit: number): string {\n return value.length > limit ? `${value.slice(0, limit)}…` : value;\n}\n"],"mappings":";;;;;;;;;;;;;;;AA+CA,SAAgB,iBAAiB,KAAa,QAA0B,CAAC,GAAW;CAClF,GAAG,UAAU,KAAK,EAAE,WAAW,KAAK,CAAC;CACrC,MAAM,QAAQ,MAAM;CAMpB,MAAM,SAAsB;EAC1B,MAAM;EACN,MAAM,KAAK,IAAI;EACf,WAAW,MAAM;EACjB,kBAAkB,MAAM,cAAc,KAAA,IAAY,oBAAoB,GAAG,IAAI,KAAA;EAC7E,QAAQ,MAAM,UAAU,eAAe,KAAK;EAC5C,WAAW,OAAO;EAClB,WAAW,OAAO;EAClB,SAAS,SAAS,OAAO,OAAO,WAAW,MAAM,SAAS,EAAE,GAAG,GAAI;EACnE,UAAU,MAAM;EAChB,QAAQ,MAAM;EACd,YAAY,SAAS,MAAM,cAAc,IAAI,GAAI;EACjD,eAAe,UAAU;CAC3B;CACA,MAAM,OAAO,KAAK,KAAK,KAAK,GAAG,kBAAkB,GAAG,OAAO,KAAK,QAAQ;CACxE,GAAG,cAAc,MAAM,GAAG,KAAK,UAAU,MAAM,EAAE,GAAG;CACpD,OAAO;AACT;;AAGA,SAAgB,iBAAiB,KAA4B;CAC3D,IAAI;CACJ,IAAI;EACF,QAAQ,GAAG,YAAY,GAAG;CAC5B,QAAQ;EACN,OAAO,CAAC;CACV;CACA,MAAM,UAAyB,CAAC;CAChC,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,CAAC,KAAK,WAAW,QAAuB,KAAK,CAAC,KAAK,SAAS,SAAS,GAAG;EAC5E,IAAI;GACF,MAAM,QAAQ,GAAG,aAAa,KAAK,KAAK,KAAK,IAAI,GAAG,MAAM,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC;GACxE,QAAQ,KAAK,KAAK,MAAM,KAAK,CAAgB;EAC/C,QAAQ,CAGR;CACF;CACA,OAAO,QAAQ,MAAM,KAAK,QAAQ,IAAI,OAAO,IAAI,IAAI;AACvD;;;;;;;;;;;AAYA,SAAgB,gBACd,QACA,KACA,UAA4B,CAAC,GACvB;CAGN,IAAI,WAAW;CACf,OAAO,GAAG,UAAU,UAAiB;EACnC,WAAW;EACX,MAAM,aAAa,iBAAiB,KAAK;GAAE;GAAO,WAAW,QAAQ;EAAU,CAAC;EAChF,QAAQ,UAAU;GAAE,MAAM;GAAS;GAAO;EAAW,CAAC;CACxD,CAAC;CACD,OAAO,GAAG,SAAS,SAAiB;EAClC,IAAI,SAAS,KAAK,UAAU;EAC5B,MAAM,aAAa,iBAAiB,KAAK;GACvC,QAAQ;GACR;GACA,WAAW,QAAQ;EACrB,CAAC;EACD,QAAQ,UAAU;GAAE,MAAM;GAAQ;GAAM;EAAW,CAAC;CACtD,CAAC;AACH;;;;;;;;;;;;;;;;;AAoBA,SAAS,YAA0C;CACjD,IAAI;EACF,OAAO;GACL,KAAK,QAAQ,YAAY,IAAI;GAC7B,QAAQ,QAAQ,cAAc,CAAC,CAAC,SAAS;GACzC,YAAY,GAAG,QAAQ;GACvB,aAAa,GAAG,SAAS;EAC3B;CACF,QAAQ;EAGN;CACF;AACF;AAKA,SAAS,oBAAoB,KAAiC;CAC5D,MAAM,OAAO,aAAa,GAAG,CAAC,CAAC,MAAM,YAAY,QAAQ,OAAO;CAChE,OAAO,OAAO,kBAAkB,KAAK,IAAI,IAAI,KAAA;AAC/C;AAEA,SAAS,eAAe,EAAE,OAAO,MAAM,UAAyC;CAE9E,IADmB,OAAyC,SAC1C,4BAA4B,OAAO;CACrD,IAAI,WAAW,WAAW,OAAO;CACjC,IAAI,WAAW,aAAa,SAAS,KAAK,OAAO;CACjD,IAAI,OAAO,SAAS,YAAY,SAAS,GAAG,OAAO;CACnD,OAAO;AACT;AAEA,SAAS,SAAS,OAAe,OAAuB;CACtD,OAAO,MAAM,SAAS,QAAQ,GAAG,MAAM,MAAM,GAAG,KAAK,EAAE,KAAK;AAC9D"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@milaboratories/pl-crash-recorder",
3
+ "version": "0.3.0",
4
+ "description": "Crash-survivable diagnostics for the block model layer: records join shapes and memory, and explains an out-of-memory death after the fact",
5
+ "keywords": [],
6
+ "license": "UNLICENSED",
7
+ "files": [
8
+ "./dist/**/*",
9
+ "./src/**/*"
10
+ ],
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@milaboratories/pl-model-common": "1.49.0"
23
+ },
24
+ "devDependencies": {
25
+ "@vitest/coverage-istanbul": "^4.1.3",
26
+ "typescript": "7.0.2",
27
+ "vitest": "^4.1.3",
28
+ "@milaboratories/build-configs": "2.0.1",
29
+ "@milaboratories/ts-configs": "1.4.0",
30
+ "@milaboratories/ts-builder": "1.7.2"
31
+ },
32
+ "scripts": {
33
+ "build": "ts-builder build --target node --build-config build.config.js",
34
+ "watch": "ts-builder build --target node --build-config build.config.js --watch",
35
+ "check": "ts-builder check --target node",
36
+ "formatter:check": "ts-builder formatter --check",
37
+ "linter:check": "ts-builder linter --check",
38
+ "types:check": "ts-builder type-check --target node",
39
+ "test": "vitest run --coverage",
40
+ "do-pack": "rm -f *.tgz && pnpm pack && mv *.tgz package.tgz",
41
+ "fmt": "ts-builder format"
42
+ }
43
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Counts and sizes for a column payload, never the payload.
3
+ *
4
+ * This is the one place that has to know the shape of `DataInfo`, because the
5
+ * numbers that predict a join's cost — rows per partition and their byte sizes —
6
+ * live at type-specific positions inside it. Everything else about a definition
7
+ * is recorded structurally.
8
+ *
9
+ * Chunk statistics are optional: the producing workflow fills them in, so row
10
+ * counts are reported when present and left unknown otherwise rather than
11
+ * guessed.
12
+ */
13
+
14
+ export type DataSummary = {
15
+ kind: string;
16
+ /** Entries for inline or JSON payloads. */
17
+ entries?: number;
18
+ approxBytes?: number;
19
+ keyLength?: number;
20
+ partitionKeyLength?: number;
21
+ parts?: number;
22
+ partsWithStats?: number;
23
+ rows?: number;
24
+ bytes?: number;
25
+ /**
26
+ * Distinct values per axis, in the order the column declares its axes.
27
+ *
28
+ * Counted independently, so multiplying them gives an upper bound on the
29
+ * distinct key tuples rather than their number: axes are usually correlated,
30
+ * and a product invents combinations that never occur. Use `distinctKeys`
31
+ * where the key in question is the whole tuple.
32
+ */
33
+ axisCardinality?: number[];
34
+ /**
35
+ * Distinct whole key tuples, which is exact where a join keys on every axis
36
+ * the column has — the ordinary case for two columns sharing their key.
37
+ */
38
+ distinctKeys?: number;
39
+ /** Set when the entries were too many to count distinct keys over. */
40
+ axisCardinalityUncounted?: boolean;
41
+ };
42
+
43
+ export function summarizeData(data: unknown): DataSummary {
44
+ if (data === null || data === undefined) return { kind: "absent" };
45
+ if (Array.isArray(data)) {
46
+ // Inline values, built inside the model sandbox.
47
+ return {
48
+ kind: "inline",
49
+ entries: data.length,
50
+ approxBytes: approxInlineBytes(data),
51
+ ...inlineAxisCardinality(data),
52
+ };
53
+ }
54
+ if (typeof data !== "object") return { kind: typeof data };
55
+
56
+ const info = data as { type?: string; [key: string]: unknown };
57
+ switch (info.type) {
58
+ case "Json":
59
+ return {
60
+ kind: "Json",
61
+ keyLength: numberOr(info.keyLength),
62
+ entries: countKeys(info.data),
63
+ };
64
+ case "JsonPartitioned":
65
+ case "BinaryPartitioned":
66
+ return {
67
+ kind: info.type,
68
+ partitionKeyLength: numberOr(info.partitionKeyLength),
69
+ parts: countKeys(info.parts),
70
+ };
71
+ case "ParquetPartitioned":
72
+ return summarizeParquet(info);
73
+ default:
74
+ return { kind: info.type ?? opaqueKind(data) };
75
+ }
76
+ }
77
+
78
+ // Internals
79
+
80
+ function summarizeParquet(info: { [key: string]: unknown }): DataSummary {
81
+ const parts = Object.values((info.parts ?? {}) as Record<string, unknown>);
82
+ let rows = 0;
83
+ let bytes = 0;
84
+ let withStats = 0;
85
+ for (const part of parts) {
86
+ const stats = (
87
+ part as { stats?: { numberOfRows?: number; size?: { axes?: number[]; column?: number } } }
88
+ )?.stats;
89
+ if (!stats) continue;
90
+ withStats++;
91
+ if (typeof stats.numberOfRows === "number") rows += stats.numberOfRows;
92
+ if (stats.size) bytes += (stats.size.column ?? 0) + sum(stats.size.axes ?? []);
93
+ }
94
+ return {
95
+ kind: "ParquetPartitioned",
96
+ partitionKeyLength: numberOr(info.partitionKeyLength),
97
+ parts: parts.length,
98
+ partsWithStats: withStats,
99
+ rows: withStats > 0 ? rows : undefined,
100
+ bytes: withStats > 0 ? bytes : undefined,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * How many entries may be walked to count distinct axis keys.
106
+ *
107
+ * Counting is exact and needs a set per axis, so it costs memory in proportion
108
+ * to the distinct keys it finds — which is the wrong thing to spend in the
109
+ * situation this code exists to diagnose. Past the cap the count is declined
110
+ * rather than approximated, so a number that is present is always true.
111
+ */
112
+ const CARDINALITY_LIMIT = 100_000;
113
+
114
+ function inlineAxisCardinality(values: unknown[]): {
115
+ axisCardinality?: number[];
116
+ distinctKeys?: number;
117
+ axisCardinalityUncounted?: boolean;
118
+ } {
119
+ if (values.length > CARDINALITY_LIMIT) return { axisCardinalityUncounted: true };
120
+ const firstKey = (values[0] as { key?: unknown } | undefined)?.key;
121
+ if (!Array.isArray(firstKey)) return {};
122
+
123
+ const perAxis = firstKey.map(() => new Set<unknown>());
124
+ // Counted alongside the per-axis sets rather than derived from them: the two
125
+ // are equal only when the axes vary independently, which they rarely do.
126
+ const tuples = new Set<string>();
127
+ for (const entry of values) {
128
+ const key = (entry as { key?: unknown }).key;
129
+ if (!Array.isArray(key) || key.length !== perAxis.length) return {};
130
+ for (const [axis, value] of key.entries()) perAxis[axis].add(value);
131
+ tuples.add(key.map((value) => String(value)).join("\u0000"));
132
+ }
133
+ return { axisCardinality: perAxis.map((set) => set.size), distinctKeys: tuples.size };
134
+ }
135
+
136
+ // Sampled rather than measured: walking millions of entries to size them is
137
+ // itself a memory risk in the situation this code exists to diagnose.
138
+ function approxInlineBytes(values: unknown[]): number {
139
+ const sampleSize = Math.min(values.length, 64);
140
+ if (sampleSize === 0) return 0;
141
+ let bytes = 0;
142
+ for (let i = 0; i < sampleSize; i++) {
143
+ const value = values[Math.floor((i * values.length) / sampleSize)];
144
+ bytes += JSON.stringify(value ?? null)?.length ?? 0;
145
+ }
146
+ return Math.round((bytes / sampleSize) * values.length);
147
+ }
148
+
149
+ function countKeys(value: unknown): number | undefined {
150
+ return value && typeof value === "object" ? Object.keys(value).length : undefined;
151
+ }
152
+
153
+ function numberOr(value: unknown): number | undefined {
154
+ return typeof value === "number" ? value : undefined;
155
+ }
156
+
157
+ function opaqueKind(value: object): string {
158
+ return (value as { constructor?: { name?: string } }).constructor?.name ?? "opaque";
159
+ }
160
+
161
+ function sum(values: number[]): number {
162
+ return values.reduce((acc, value) => acc + value, 0);
163
+ }
package/src/digest.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { redact, type RedactionStats } from "./redact";
2
+
3
+ /**
4
+ * What a definition record carries.
5
+ *
6
+ * The definition is recorded structurally (see `redact`) rather than through a
7
+ * hand-written digest per definition type. Structural rules are not run here:
8
+ * they belong to the analyzer, so nothing is computed on the hot path and the
9
+ * rules can be revised against logs that already exist.
10
+ */
11
+
12
+ export type DefKind = "PTableDef" | "PTableDefV2" | "PFrameDef";
13
+
14
+ export type DefDigest = {
15
+ kind: DefKind;
16
+ /** Redacted definition, same shape as the original. */
17
+ def: unknown;
18
+ redaction: RedactionStats & { bytes: number };
19
+ };
20
+
21
+ /** Records one definition: redacted, measured, and tagged with its API shape. */
22
+ export function digestDef(kind: DefKind, def: unknown): DefDigest {
23
+ const { value, stats } = redact(def);
24
+ const json = safeLength(value);
25
+ return { kind, def: value, redaction: { ...stats, bytes: json } };
26
+ }
27
+
28
+ // Internals
29
+
30
+ function safeLength(value: unknown): number {
31
+ try {
32
+ return JSON.stringify(value)?.length ?? 0;
33
+ } catch {
34
+ return 0;
35
+ }
36
+ }
package/src/events.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Record types written to a crash log.
3
+ *
4
+ * The log is append-only NDJSON, one record per line, and is read back by
5
+ * tooling that may be older or newer than the writer, so every field beyond
6
+ * {@link LogRecordBase} is optional and unknown record types are skipped
7
+ * rather than rejected.
8
+ */
9
+
10
+ /** Memory reading taken by the thread that wrote the record. */
11
+ export type MemorySnapshot = {
12
+ /** Resident set size of the whole process. */
13
+ rss: number;
14
+ /** Heap in use by the writing thread's isolate. */
15
+ heapUsed: number;
16
+ heapTotal: number;
17
+ external: number;
18
+ arrayBuffers: number;
19
+ /** V8 heap ceiling for the writing thread's isolate. */
20
+ heapLimit: number;
21
+ };
22
+
23
+ type LogRecordBase = {
24
+ /** Monotonically increasing within one session; used to pair begin with end. */
25
+ seq: number;
26
+ /** Milliseconds since process start, for durations. */
27
+ t: number;
28
+ /** Wall clock, for correlating with the sampler series and crash markers. */
29
+ wall: number;
30
+ type: string;
31
+ };
32
+
33
+ export type LogRecord = LogRecordBase & {
34
+ mem?: MemorySnapshot;
35
+ /** Sequence number of the matching begin record, on end and error records. */
36
+ begin?: number;
37
+ [key: string]: unknown;
38
+ };
39
+
40
+ /** Session header, always the first record. */
41
+ export type SessionEnvironment = {
42
+ node: string;
43
+ platform: string;
44
+ cpus: number;
45
+ totalMemory: number;
46
+ heapLimit: number;
47
+ execArgv: string[];
48
+ maxOldSpaceSize?: number;
49
+ };
50
+
51
+ /** Written by the sampler thread to its own sibling file. */
52
+ export type SamplerRecord = LogRecordBase & {
53
+ type: "mem-sampler";
54
+ rss: number;
55
+ peakRss: number;
56
+ /** Highest resident size the kernel has seen for this process. */
57
+ maxRss?: number;
58
+ freeMemory: number;
59
+ totalMemory: number;
60
+ /** Where the machine's memory actually is, refreshed less often than `rss`. */
61
+ machine?: MachineMemory;
62
+ };
63
+
64
+ /**
65
+ * A reading only the application process can take.
66
+ *
67
+ * Resident size falls when the OS compresses or pages a process out, so it
68
+ * cannot say whether the memory was released or merely moved. Private bytes can:
69
+ * they are unshared and stay committed until the process actually gives them
70
+ * back. That is the difference between "our process is holding this" and "the
71
+ * machine is short of memory for some other reason", which nothing else here
72
+ * distinguishes.
73
+ */
74
+ export type HostRecord = LogRecordBase & {
75
+ type: "mem-host";
76
+ /** Unshared, still-committed bytes of the process hosting the middle layer. */
77
+ private?: number;
78
+ /** What the machine has paged out, where the platform reports it. */
79
+ swapUsed?: number;
80
+ swapTotal?: number;
81
+ /** The same figure per process, where the host can enumerate its own. */
82
+ processes?: { pid: number; name?: string; private?: number }[];
83
+ };
84
+
85
+ /**
86
+ * The machine's own account of its memory.
87
+ *
88
+ * Resident size is not the whole story on a machine under pressure: macOS moves
89
+ * pages out of a process's resident set into the compressor, and both Unixes
90
+ * swap, so a process can appear to shrink while the memory it asked for is still
91
+ * held. These readings are what a resident-size curve has to be read against.
92
+ */
93
+ export type MachineMemory = {
94
+ /** Bytes of process memory the compressor holds, counted before compression. */
95
+ compressedStored?: number;
96
+ /** Physical bytes the compressor itself occupies. */
97
+ compressedOccupied?: number;
98
+ swapUsed?: number;
99
+ swapTotal?: number;
100
+ anonymous?: number;
101
+ fileBacked?: number;
102
+ wired?: number;
103
+ /** Why the reading is missing, when it is. */
104
+ unavailable?: string;
105
+ };
106
+
107
+ /** Written by the parent when a supervised thread or process dies. */
108
+ export type CrashMarker = {
109
+ type: "external-crash";
110
+ wall: number;
111
+ /**
112
+ * Session the marker belongs to. Present only when the parent assigned the id
113
+ * to the worker and therefore knows it; never inferred, because a wrong id
114
+ * here would both misattribute the death and stop the right session from
115
+ * claiming it.
116
+ */
117
+ sessionId?: string;
118
+ /**
119
+ * Advisory only: the newest open crash log at the moment of death. A
120
+ * concurrent live session can make this wrong, so it is never matched against
121
+ * — it exists to help a human read a directory by hand.
122
+ */
123
+ guessedSessionId?: string;
124
+ /** Written by an older recorder that put a guess in `sessionId`. */
125
+ sessionIdSource?: "assigned" | "guessed";
126
+ reason: CrashReason;
127
+ errorCode?: string;
128
+ errorName?: string;
129
+ message?: string;
130
+ exitCode?: number;
131
+ signal?: string;
132
+ stderrTail?: string;
133
+ /**
134
+ * What memory looked like when the death was observed, taken by the parent.
135
+ *
136
+ * Without it a marker reading `worker-exit` with exit code 1 is
137
+ * indistinguishable from an ordinary application error, and a reader who
138
+ * starts here would classify an exhausted machine as a bug in the code that
139
+ * happened to be running.
140
+ */
141
+ memoryAtDeath?: {
142
+ /** Resident size of the parent process, which hosts the dying worker. */
143
+ rss: number;
144
+ /** Highest resident size the kernel recorded for it. */
145
+ maxRss: number;
146
+ freeMemory: number;
147
+ totalMemory: number;
148
+ };
149
+ };
150
+
151
+ export type CrashReason =
152
+ | "js-heap-out-of-memory"
153
+ | "killed-by-os"
154
+ | "abort-or-fatal-allocation-failure"
155
+ | "worker-exit"
156
+ | "nonzero-exit"
157
+ | "unknown";
158
+
159
+ export const SESSION_RECORD = "session";
160
+ /** Earliest memory reading of a session, rewritten into every rotated segment. */
161
+ export const MEM_BASELINE_RECORD = "mem-baseline";
162
+ export const SESSION_END_RECORD = "session-end";
163
+ export const HOST_FILE_PREFIX = "host";
164
+ export const SESSION_FILE_PREFIX = "session";
165
+ export const SAMPLER_FILE_PREFIX = "memory";
166
+ export const DEATH_FILE_PREFIX = "death";