@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
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @milaboratories/pl-flight-recorder
2
+
3
+ Explains an out-of-memory death in the block model layer after the fact, when
4
+ the process is already gone and the data that caused it cannot be obtained.
5
+
6
+ ## Why it is built this way
7
+
8
+ The failure this exists for kills the process in seconds and runs no shutdown
9
+ path: V8 fatal out-of-memory, a failed native allocation, the OS out-of-memory
10
+ killer. That rules out anything that collects on demand from a live process —
11
+ `pprofDump`, cache metrics, a log that flushes on exit — because by the time
12
+ anyone asks, there is nothing to ask.
13
+
14
+ Three consequences shape the design:
15
+
16
+ - **Records are appended synchronously.** A buffered stream loses exactly the
17
+ tail that explains the death. The absence of a terminating `session-end`
18
+ record is how a crash is detected.
19
+ - **Memory is sampled from another thread.** While the middle layer sits inside
20
+ a synchronous pframes call its own timers do not fire, so its memory series
21
+ goes dark precisely when memory is growing fastest.
22
+ - **The parent records the cause.** A thread that runs out of heap cannot
23
+ describe its own death; its last reading predates the blow-up. Node reports
24
+ `ERR_WORKER_OUT_OF_MEMORY` to the parent, which turns a guess into a fact.
25
+
26
+ ## Enabling it
27
+
28
+ Inert unless `MI_FLIGHT_RECORDER_DIR` points at a directory. Recording appends
29
+ on every recorded operation and that cost has not yet been measured against a
30
+ real project, so it is opt-in rather than on by default.
31
+
32
+ ```
33
+ MI_FLIGHT_RECORDER_DIR=~/platforma-flight <start the app>
34
+ ```
35
+
36
+ ## Reading a log
37
+
38
+ ```ts
39
+ import { analyzeLatest, renderReport } from "@milaboratories/pl-flight-recorder";
40
+
41
+ const analysis = analyzeLatest(dir); // prefers the session that crashed
42
+ console.log(renderReport(analysis));
43
+ ```
44
+
45
+ The verdict combines three independent lines of evidence, none sufficient alone:
46
+
47
+ 1. **What was in flight.** Every operation writes a begin and an end record, so
48
+ an unmatched begin names the exact call that was running when memory ran out.
49
+ 2. **Which memory ran out.** JS heap (confirmed by the parent), off-heap
50
+ ArrayBuffers handed out by the engine, native allocation, or the machine.
51
+ This decides whether `--max-old-space-size` is even relevant.
52
+ 3. **Why the data was that big.** Structural faults readable from the recorded
53
+ shape before any data is touched — a cartesian join, or axes that agree on
54
+ name and type but differ in domain — plus the observed row count against the
55
+ input the definition declared.
56
+
57
+ ## What is recorded, and how
58
+
59
+ A definition is recorded by **shape**, not by a hand-written digest per
60
+ definition type. `redact` walks whatever the seam was handed and rebuilds it:
61
+ keys, numbers and the small set of strings that are schema survive verbatim,
62
+ every other string becomes a hash and a length, and a column payload is replaced
63
+ by counts. The default for an unrecognised string is to hash it, so a field this
64
+ code has never seen can make a report less informative but never make it leak.
65
+
66
+ - Kept: definition shape, column and axis names, value types, axis domains
67
+ (they carry join identity), filter operators, row/byte/partition counts.
68
+ - Hashed: filter reference values, annotation values (`pl7.app/label` carries
69
+ user-entered sample names), column ids, every other string. The hash is
70
+ stable, so two labels can be compared without either being revealed.
71
+ - Dropped: cell values, inline column payloads, partition keys.
72
+
73
+ Two things bound one record: arrays keep their head and carry an `$omitted`
74
+ marker, and a node budget stops a pathological definition from filling the log.
75
+ A session's log is bounded the same way, at two segments of 32 MiB. Rotation
76
+ rewrites into the new segment the three things a report cannot be produced
77
+ without — the session header, the earliest memory reading, and the begin record
78
+ of every operation still open — so repeated rotation costs only operations that
79
+ already completed. The report states how many rotations happened.
80
+ Class instances are named (`$opaque`) rather than walked, which matters because
81
+ a definition can carry live accessors that reference each other.
82
+
83
+ On a realistic definition (two columns, 72 partitions, a 1200-value filter) the
84
+ record is ~1 KB against ~23 KB for the definition as it stands — but size is not
85
+ why this exists. The definition as it stands cannot be sent to us at all.
86
+
87
+ Only one module knows a definition's types: `data_summary`, which reads row
88
+ counts and byte sizes out of `DataInfo` because those numbers live at
89
+ type-specific positions and they are what predicts a join's cost.
90
+
91
+ ## Rules run in the analyzer
92
+
93
+ Structural rules are not evaluated while recording. Nothing is computed on the
94
+ hot path, the rules can be revised against logs that already exist, and one
95
+ implementation covers every definition shape: join nodes are recognised by their
96
+ discriminator and their children by position, so the original tree API and the
97
+ V2 query API are read by the same walk.
98
+
99
+ ## Where it hooks in
100
+
101
+ - `pl-middle-layer/src/middle_layer/driver_kit.ts` wraps the pFrame driver once.
102
+ Every join a model builds and every row it reads passes through it, so both
103
+ the creation calls and the data calls are covered without touching call sites.
104
+ - `pl-middle-layer/src/js_render/index.ts` opens a render span per block, and one
105
+ per resumption of a deferred render. Driver calls carry no block identity of
106
+ their own, so the enclosing span is what attributes a join to a block.
107
+
108
+ `executeSingleLambda` is deliberately not instrumented: it evaluates `args()`
109
+ style lambdas with no computable context, so it reaches no driver.
110
+
111
+ ## Known gaps
112
+
113
+ - The synchronous append cost is unmeasured on a real project; the sampler
114
+ interval and event granularity should be revisited with that number in hand.
115
+ - A table view can die in the renderer process rather than in the middle-layer
116
+ worker. That path needs `render-process-gone` in the desktop app and is not
117
+ covered here.
118
+ - Domain values are kept because they carry join identity. If any producer puts
119
+ a sample name in a domain value, it needs hashing too.
@@ -0,0 +1,100 @@
1
+ //#region src/data_summary.ts
2
+ function summarizeData(data) {
3
+ if (data === null || data === void 0) return { kind: "absent" };
4
+ if (Array.isArray(data)) return {
5
+ kind: "inline",
6
+ entries: data.length,
7
+ approxBytes: approxInlineBytes(data),
8
+ ...inlineAxisCardinality(data)
9
+ };
10
+ if (typeof data !== "object") return { kind: typeof data };
11
+ const info = data;
12
+ switch (info.type) {
13
+ case "Json": return {
14
+ kind: "Json",
15
+ keyLength: numberOr(info.keyLength),
16
+ entries: countKeys(info.data)
17
+ };
18
+ case "JsonPartitioned":
19
+ case "BinaryPartitioned": return {
20
+ kind: info.type,
21
+ partitionKeyLength: numberOr(info.partitionKeyLength),
22
+ parts: countKeys(info.parts)
23
+ };
24
+ case "ParquetPartitioned": return summarizeParquet(info);
25
+ default: return { kind: info.type ?? opaqueKind(data) };
26
+ }
27
+ }
28
+ function summarizeParquet(info) {
29
+ const parts = Object.values(info.parts ?? {});
30
+ let rows = 0;
31
+ let bytes = 0;
32
+ let withStats = 0;
33
+ for (const part of parts) {
34
+ const stats = part?.stats;
35
+ if (!stats) continue;
36
+ withStats++;
37
+ if (typeof stats.numberOfRows === "number") rows += stats.numberOfRows;
38
+ if (stats.size) bytes += (stats.size.column ?? 0) + sum(stats.size.axes ?? []);
39
+ }
40
+ return {
41
+ kind: "ParquetPartitioned",
42
+ partitionKeyLength: numberOr(info.partitionKeyLength),
43
+ parts: parts.length,
44
+ partsWithStats: withStats,
45
+ rows: withStats > 0 ? rows : void 0,
46
+ bytes: withStats > 0 ? bytes : void 0
47
+ };
48
+ }
49
+ /**
50
+ * How many entries may be walked to count distinct axis keys.
51
+ *
52
+ * Counting is exact and needs a set per axis, so it costs memory in proportion
53
+ * to the distinct keys it finds — which is the wrong thing to spend in the
54
+ * situation this code exists to diagnose. Past the cap the count is declined
55
+ * rather than approximated, so a number that is present is always true.
56
+ */
57
+ const CARDINALITY_LIMIT = 1e5;
58
+ function inlineAxisCardinality(values) {
59
+ if (values.length > CARDINALITY_LIMIT) return { axisCardinalityUncounted: true };
60
+ const firstKey = values[0]?.key;
61
+ if (!Array.isArray(firstKey)) return {};
62
+ const perAxis = firstKey.map(() => /* @__PURE__ */ new Set());
63
+ const tuples = /* @__PURE__ */ new Set();
64
+ for (const entry of values) {
65
+ const key = entry.key;
66
+ if (!Array.isArray(key) || key.length !== perAxis.length) return {};
67
+ for (const [axis, value] of key.entries()) perAxis[axis].add(value);
68
+ tuples.add(key.map((value) => String(value)).join("\0"));
69
+ }
70
+ return {
71
+ axisCardinality: perAxis.map((set) => set.size),
72
+ distinctKeys: tuples.size
73
+ };
74
+ }
75
+ function approxInlineBytes(values) {
76
+ const sampleSize = Math.min(values.length, 64);
77
+ if (sampleSize === 0) return 0;
78
+ let bytes = 0;
79
+ for (let i = 0; i < sampleSize; i++) {
80
+ const value = values[Math.floor(i * values.length / sampleSize)];
81
+ bytes += JSON.stringify(value ?? null)?.length ?? 0;
82
+ }
83
+ return Math.round(bytes / sampleSize * values.length);
84
+ }
85
+ function countKeys(value) {
86
+ return value && typeof value === "object" ? Object.keys(value).length : void 0;
87
+ }
88
+ function numberOr(value) {
89
+ return typeof value === "number" ? value : void 0;
90
+ }
91
+ function opaqueKind(value) {
92
+ return value.constructor?.name ?? "opaque";
93
+ }
94
+ function sum(values) {
95
+ return values.reduce((acc, value) => acc + value, 0);
96
+ }
97
+ //#endregion
98
+ export { summarizeData };
99
+
100
+ //# sourceMappingURL=data_summary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data_summary.js","names":[],"sources":["../src/data_summary.ts"],"sourcesContent":["/**\n * Counts and sizes for a column payload, never the payload.\n *\n * This is the one place that has to know the shape of `DataInfo`, because the\n * numbers that predict a join's cost — rows per partition and their byte sizes —\n * live at type-specific positions inside it. Everything else about a definition\n * is recorded structurally.\n *\n * Chunk statistics are optional: the producing workflow fills them in, so row\n * counts are reported when present and left unknown otherwise rather than\n * guessed.\n */\n\nexport type DataSummary = {\n kind: string;\n /** Entries for inline or JSON payloads. */\n entries?: number;\n approxBytes?: number;\n keyLength?: number;\n partitionKeyLength?: number;\n parts?: number;\n partsWithStats?: number;\n rows?: number;\n bytes?: number;\n /**\n * Distinct values per axis, in the order the column declares its axes.\n *\n * Counted independently, so multiplying them gives an upper bound on the\n * distinct key tuples rather than their number: axes are usually correlated,\n * and a product invents combinations that never occur. Use `distinctKeys`\n * where the key in question is the whole tuple.\n */\n axisCardinality?: number[];\n /**\n * Distinct whole key tuples, which is exact where a join keys on every axis\n * the column has — the ordinary case for two columns sharing their key.\n */\n distinctKeys?: number;\n /** Set when the entries were too many to count distinct keys over. */\n axisCardinalityUncounted?: boolean;\n};\n\nexport function summarizeData(data: unknown): DataSummary {\n if (data === null || data === undefined) return { kind: \"absent\" };\n if (Array.isArray(data)) {\n // Inline values, built inside the model sandbox.\n return {\n kind: \"inline\",\n entries: data.length,\n approxBytes: approxInlineBytes(data),\n ...inlineAxisCardinality(data),\n };\n }\n if (typeof data !== \"object\") return { kind: typeof data };\n\n const info = data as { type?: string; [key: string]: unknown };\n switch (info.type) {\n case \"Json\":\n return {\n kind: \"Json\",\n keyLength: numberOr(info.keyLength),\n entries: countKeys(info.data),\n };\n case \"JsonPartitioned\":\n case \"BinaryPartitioned\":\n return {\n kind: info.type,\n partitionKeyLength: numberOr(info.partitionKeyLength),\n parts: countKeys(info.parts),\n };\n case \"ParquetPartitioned\":\n return summarizeParquet(info);\n default:\n return { kind: info.type ?? opaqueKind(data) };\n }\n}\n\n// Internals\n\nfunction summarizeParquet(info: { [key: string]: unknown }): DataSummary {\n const parts = Object.values((info.parts ?? {}) as Record<string, unknown>);\n let rows = 0;\n let bytes = 0;\n let withStats = 0;\n for (const part of parts) {\n const stats = (\n part as { stats?: { numberOfRows?: number; size?: { axes?: number[]; column?: number } } }\n )?.stats;\n if (!stats) continue;\n withStats++;\n if (typeof stats.numberOfRows === \"number\") rows += stats.numberOfRows;\n if (stats.size) bytes += (stats.size.column ?? 0) + sum(stats.size.axes ?? []);\n }\n return {\n kind: \"ParquetPartitioned\",\n partitionKeyLength: numberOr(info.partitionKeyLength),\n parts: parts.length,\n partsWithStats: withStats,\n rows: withStats > 0 ? rows : undefined,\n bytes: withStats > 0 ? bytes : undefined,\n };\n}\n\n/**\n * How many entries may be walked to count distinct axis keys.\n *\n * Counting is exact and needs a set per axis, so it costs memory in proportion\n * to the distinct keys it finds — which is the wrong thing to spend in the\n * situation this code exists to diagnose. Past the cap the count is declined\n * rather than approximated, so a number that is present is always true.\n */\nconst CARDINALITY_LIMIT = 100_000;\n\nfunction inlineAxisCardinality(values: unknown[]): {\n axisCardinality?: number[];\n distinctKeys?: number;\n axisCardinalityUncounted?: boolean;\n} {\n if (values.length > CARDINALITY_LIMIT) return { axisCardinalityUncounted: true };\n const firstKey = (values[0] as { key?: unknown } | undefined)?.key;\n if (!Array.isArray(firstKey)) return {};\n\n const perAxis = firstKey.map(() => new Set<unknown>());\n // Counted alongside the per-axis sets rather than derived from them: the two\n // are equal only when the axes vary independently, which they rarely do.\n const tuples = new Set<string>();\n for (const entry of values) {\n const key = (entry as { key?: unknown }).key;\n if (!Array.isArray(key) || key.length !== perAxis.length) return {};\n for (const [axis, value] of key.entries()) perAxis[axis].add(value);\n tuples.add(key.map((value) => String(value)).join(\"\\u0000\"));\n }\n return { axisCardinality: perAxis.map((set) => set.size), distinctKeys: tuples.size };\n}\n\n// Sampled rather than measured: walking millions of entries to size them is\n// itself a memory risk in the situation this code exists to diagnose.\nfunction approxInlineBytes(values: unknown[]): number {\n const sampleSize = Math.min(values.length, 64);\n if (sampleSize === 0) return 0;\n let bytes = 0;\n for (let i = 0; i < sampleSize; i++) {\n const value = values[Math.floor((i * values.length) / sampleSize)];\n bytes += JSON.stringify(value ?? null)?.length ?? 0;\n }\n return Math.round((bytes / sampleSize) * values.length);\n}\n\nfunction countKeys(value: unknown): number | undefined {\n return value && typeof value === \"object\" ? Object.keys(value).length : undefined;\n}\n\nfunction numberOr(value: unknown): number | undefined {\n return typeof value === \"number\" ? value : undefined;\n}\n\nfunction opaqueKind(value: object): string {\n return (value as { constructor?: { name?: string } }).constructor?.name ?? \"opaque\";\n}\n\nfunction sum(values: number[]): number {\n return values.reduce((acc, value) => acc + value, 0);\n}\n"],"mappings":"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"}
package/dist/digest.js ADDED
@@ -0,0 +1,26 @@
1
+ import { redact } from "./redact.js";
2
+ //#region src/digest.ts
3
+ /** Records one definition: redacted, measured, and tagged with its API shape. */
4
+ function digestDef(kind, def) {
5
+ const { value, stats } = redact(def);
6
+ const json = safeLength(value);
7
+ return {
8
+ kind,
9
+ def: value,
10
+ redaction: {
11
+ ...stats,
12
+ bytes: json
13
+ }
14
+ };
15
+ }
16
+ function safeLength(value) {
17
+ try {
18
+ return JSON.stringify(value)?.length ?? 0;
19
+ } catch {
20
+ return 0;
21
+ }
22
+ }
23
+ //#endregion
24
+ export { digestDef };
25
+
26
+ //# sourceMappingURL=digest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"digest.js","names":[],"sources":["../src/digest.ts"],"sourcesContent":["import { redact, type RedactionStats } from \"./redact\";\n\n/**\n * What a definition record carries.\n *\n * The definition is recorded structurally (see `redact`) rather than through a\n * hand-written digest per definition type. Structural rules are not run here:\n * they belong to the analyzer, so nothing is computed on the hot path and the\n * rules can be revised against logs that already exist.\n */\n\nexport type DefKind = \"PTableDef\" | \"PTableDefV2\" | \"PFrameDef\";\n\nexport type DefDigest = {\n kind: DefKind;\n /** Redacted definition, same shape as the original. */\n def: unknown;\n redaction: RedactionStats & { bytes: number };\n};\n\n/** Records one definition: redacted, measured, and tagged with its API shape. */\nexport function digestDef(kind: DefKind, def: unknown): DefDigest {\n const { value, stats } = redact(def);\n const json = safeLength(value);\n return { kind, def: value, redaction: { ...stats, bytes: json } };\n}\n\n// Internals\n\nfunction safeLength(value: unknown): number {\n try {\n return JSON.stringify(value)?.length ?? 0;\n } catch {\n return 0;\n }\n}\n"],"mappings":";;;AAqBA,SAAgB,UAAU,MAAe,KAAyB;CAChE,MAAM,EAAE,OAAO,UAAU,OAAO,GAAG;CACnC,MAAM,OAAO,WAAW,KAAK;CAC7B,OAAO;EAAE;EAAM,KAAK;EAAO,WAAW;GAAE,GAAG;GAAO,OAAO;EAAK;CAAE;AAClE;AAIA,SAAS,WAAW,OAAwB;CAC1C,IAAI;EACF,OAAO,KAAK,UAAU,KAAK,CAAC,EAAE,UAAU;CAC1C,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,149 @@
1
+ //#region src/events.d.ts
2
+ /**
3
+ * Record types written to a crash log.
4
+ *
5
+ * The log is append-only NDJSON, one record per line, and is read back by
6
+ * tooling that may be older or newer than the writer, so every field beyond
7
+ * {@link LogRecordBase} is optional and unknown record types are skipped
8
+ * rather than rejected.
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
+ type LogRecordBase = {
23
+ /** Monotonically increasing within one session; used to pair begin with end. */
24
+ seq: number;
25
+ /** Milliseconds since process start, for durations. */
26
+ t: number;
27
+ /** Wall clock, for correlating with the sampler series and crash markers. */
28
+ wall: number;
29
+ type: string;
30
+ };
31
+ export type LogRecord = LogRecordBase & {
32
+ mem?: MemorySnapshot;
33
+ /** Sequence number of the matching begin record, on end and error records. */
34
+ begin?: number;
35
+ [key: string]: unknown;
36
+ };
37
+ /** Session header, always the first record. */
38
+ export type SessionEnvironment = {
39
+ node: string;
40
+ platform: string;
41
+ cpus: number;
42
+ totalMemory: number;
43
+ heapLimit: number;
44
+ execArgv: string[];
45
+ maxOldSpaceSize?: number;
46
+ };
47
+ /** Written by the sampler thread to its own sibling file. */
48
+ export type SamplerRecord = LogRecordBase & {
49
+ type: "mem-sampler";
50
+ rss: number;
51
+ peakRss: number;
52
+ /** Highest resident size the kernel has seen for this process. */
53
+ maxRss?: number;
54
+ freeMemory: number;
55
+ totalMemory: number;
56
+ /** Where the machine's memory actually is, refreshed less often than `rss`. */
57
+ machine?: MachineMemory;
58
+ };
59
+ /**
60
+ * A reading only the application process can take.
61
+ *
62
+ * Resident size falls when the OS compresses or pages a process out, so it
63
+ * cannot say whether the memory was released or merely moved. Private bytes can:
64
+ * they are unshared and stay committed until the process actually gives them
65
+ * back. That is the difference between "our process is holding this" and "the
66
+ * machine is short of memory for some other reason", which nothing else here
67
+ * distinguishes.
68
+ */
69
+ export type HostRecord = LogRecordBase & {
70
+ type: "mem-host";
71
+ /** Unshared, still-committed bytes of the process hosting the middle layer. */
72
+ private?: number;
73
+ /** What the machine has paged out, where the platform reports it. */
74
+ swapUsed?: number;
75
+ swapTotal?: number;
76
+ /** The same figure per process, where the host can enumerate its own. */
77
+ processes?: {
78
+ pid: number;
79
+ name?: string;
80
+ private?: number;
81
+ }[];
82
+ };
83
+ /**
84
+ * The machine's own account of its memory.
85
+ *
86
+ * Resident size is not the whole story on a machine under pressure: macOS moves
87
+ * pages out of a process's resident set into the compressor, and both Unixes
88
+ * swap, so a process can appear to shrink while the memory it asked for is still
89
+ * held. These readings are what a resident-size curve has to be read against.
90
+ */
91
+ export type MachineMemory = {
92
+ /** Bytes of process memory the compressor holds, counted before compression. */
93
+ compressedStored?: number;
94
+ /** Physical bytes the compressor itself occupies. */
95
+ compressedOccupied?: number;
96
+ swapUsed?: number;
97
+ swapTotal?: number;
98
+ anonymous?: number;
99
+ fileBacked?: number;
100
+ wired?: number;
101
+ /** Why the reading is missing, when it is. */
102
+ unavailable?: string;
103
+ };
104
+ /** Written by the parent when a supervised thread or process dies. */
105
+ export type CrashMarker = {
106
+ type: "external-crash";
107
+ wall: number;
108
+ /**
109
+ * Session the marker belongs to. Present only when the parent assigned the id
110
+ * to the worker and therefore knows it; never inferred, because a wrong id
111
+ * here would both misattribute the death and stop the right session from
112
+ * claiming it.
113
+ */
114
+ sessionId?: string;
115
+ /**
116
+ * Advisory only: the newest open crash log at the moment of death. A
117
+ * concurrent live session can make this wrong, so it is never matched against
118
+ * — it exists to help a human read a directory by hand.
119
+ */
120
+ guessedSessionId?: string;
121
+ /** Written by an older recorder that put a guess in `sessionId`. */
122
+ sessionIdSource?: "assigned" | "guessed";
123
+ reason: CrashReason;
124
+ errorCode?: string;
125
+ errorName?: string;
126
+ message?: string;
127
+ exitCode?: number;
128
+ signal?: string;
129
+ stderrTail?: string;
130
+ /**
131
+ * What memory looked like when the death was observed, taken by the parent.
132
+ *
133
+ * Without it a marker reading `worker-exit` with exit code 1 is
134
+ * indistinguishable from an ordinary application error, and a reader who
135
+ * starts here would classify an exhausted machine as a bug in the code that
136
+ * happened to be running.
137
+ */
138
+ memoryAtDeath?: {
139
+ /** Resident size of the parent process, which hosts the dying worker. */
140
+ rss: number;
141
+ /** Highest resident size the kernel recorded for it. */
142
+ maxRss: number;
143
+ freeMemory: number;
144
+ totalMemory: number;
145
+ };
146
+ };
147
+ export type CrashReason = "js-heap-out-of-memory" | "killed-by-os" | "abort-or-fatal-allocation-failure" | "worker-exit" | "nonzero-exit" | "unknown";
148
+ //#endregion
149
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","names":[],"sources":["../src/events.ts"],"mappings":";;;;;;;;;;YAUY;;EAEV;;EAEA;EACA;EACA;EACA;;EAEA;;KAGG;;EAEH;;EAEA;;EAEA;EACA;;YAGU,YAAY;EACtB,MAAM;;EAEN;GACC;;;YAIS;EACV;EACA;EACA;EACA;EACA;EACA;EACA;;;YAIU,gBAAgB;EAC1B;EACA;EACA;;EAEA;EACA;EACA;;EAEA,UAAU;;;;;;;;;;;;YAaA,aAAa;EACvB;;EAEA;;EAEA;EACA;;EAEA;IAAc;IAAa;IAAe;;;;;;;;;;;YAWhC;;EAEV;;EAEA;EACA;EACA;EACA;EACA;EACA;;EAEA;;;YAIU;EACV;EACA;;;;;;;EAOA;;;;;;EAMA;;EAEA;EACA,QAAQ;EACR;EACA;EACA;EACA;EACA;EACA;;;;;;;;;EASA;;IAEE;;IAEA;IACA;IACA;;;YAIQ"}
package/dist/events.js ADDED
@@ -0,0 +1,13 @@
1
+ //#region src/events.ts
2
+ const SESSION_RECORD = "session";
3
+ /** Earliest memory reading of a session, rewritten into every rotated segment. */
4
+ const MEM_BASELINE_RECORD = "mem-baseline";
5
+ const SESSION_END_RECORD = "session-end";
6
+ const HOST_FILE_PREFIX = "host";
7
+ const SESSION_FILE_PREFIX = "session";
8
+ const SAMPLER_FILE_PREFIX = "memory";
9
+ const DEATH_FILE_PREFIX = "death";
10
+ //#endregion
11
+ export { DEATH_FILE_PREFIX, HOST_FILE_PREFIX, MEM_BASELINE_RECORD, SAMPLER_FILE_PREFIX, SESSION_END_RECORD, SESSION_FILE_PREFIX, SESSION_RECORD };
12
+
13
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","names":[],"sources":["../src/events.ts"],"sourcesContent":["/**\n * Record types written to a crash log.\n *\n * The log is append-only NDJSON, one record per line, and is read back by\n * tooling that may be older or newer than the writer, so every field beyond\n * {@link LogRecordBase} is optional and unknown record types are skipped\n * rather than rejected.\n */\n\n/** Memory reading taken by the thread that wrote the record. */\nexport type MemorySnapshot = {\n /** Resident set size of the whole process. */\n rss: number;\n /** Heap in use by the writing thread's isolate. */\n heapUsed: number;\n heapTotal: number;\n external: number;\n arrayBuffers: number;\n /** V8 heap ceiling for the writing thread's isolate. */\n heapLimit: number;\n};\n\ntype LogRecordBase = {\n /** Monotonically increasing within one session; used to pair begin with end. */\n seq: number;\n /** Milliseconds since process start, for durations. */\n t: number;\n /** Wall clock, for correlating with the sampler series and crash markers. */\n wall: number;\n type: string;\n};\n\nexport type LogRecord = LogRecordBase & {\n mem?: MemorySnapshot;\n /** Sequence number of the matching begin record, on end and error records. */\n begin?: number;\n [key: string]: unknown;\n};\n\n/** Session header, always the first record. */\nexport type SessionEnvironment = {\n node: string;\n platform: string;\n cpus: number;\n totalMemory: number;\n heapLimit: number;\n execArgv: string[];\n maxOldSpaceSize?: number;\n};\n\n/** Written by the sampler thread to its own sibling file. */\nexport type SamplerRecord = LogRecordBase & {\n type: \"mem-sampler\";\n rss: number;\n peakRss: number;\n /** Highest resident size the kernel has seen for this process. */\n maxRss?: number;\n freeMemory: number;\n totalMemory: number;\n /** Where the machine's memory actually is, refreshed less often than `rss`. */\n machine?: MachineMemory;\n};\n\n/**\n * A reading only the application process can take.\n *\n * Resident size falls when the OS compresses or pages a process out, so it\n * cannot say whether the memory was released or merely moved. Private bytes can:\n * they are unshared and stay committed until the process actually gives them\n * back. That is the difference between \"our process is holding this\" and \"the\n * machine is short of memory for some other reason\", which nothing else here\n * distinguishes.\n */\nexport type HostRecord = LogRecordBase & {\n type: \"mem-host\";\n /** Unshared, still-committed bytes of the process hosting the middle layer. */\n private?: number;\n /** What the machine has paged out, where the platform reports it. */\n swapUsed?: number;\n swapTotal?: number;\n /** The same figure per process, where the host can enumerate its own. */\n processes?: { pid: number; name?: string; private?: number }[];\n};\n\n/**\n * The machine's own account of its memory.\n *\n * Resident size is not the whole story on a machine under pressure: macOS moves\n * pages out of a process's resident set into the compressor, and both Unixes\n * swap, so a process can appear to shrink while the memory it asked for is still\n * held. These readings are what a resident-size curve has to be read against.\n */\nexport type MachineMemory = {\n /** Bytes of process memory the compressor holds, counted before compression. */\n compressedStored?: number;\n /** Physical bytes the compressor itself occupies. */\n compressedOccupied?: number;\n swapUsed?: number;\n swapTotal?: number;\n anonymous?: number;\n fileBacked?: number;\n wired?: number;\n /** Why the reading is missing, when it is. */\n unavailable?: string;\n};\n\n/** Written by the parent when a supervised thread or process dies. */\nexport type CrashMarker = {\n type: \"external-crash\";\n wall: number;\n /**\n * Session the marker belongs to. Present only when the parent assigned the id\n * to the worker and therefore knows it; never inferred, because a wrong id\n * here would both misattribute the death and stop the right session from\n * claiming it.\n */\n sessionId?: string;\n /**\n * Advisory only: the newest open crash log at the moment of death. A\n * concurrent live session can make this wrong, so it is never matched against\n * — it exists to help a human read a directory by hand.\n */\n guessedSessionId?: string;\n /** Written by an older recorder that put a guess in `sessionId`. */\n sessionIdSource?: \"assigned\" | \"guessed\";\n reason: CrashReason;\n errorCode?: string;\n errorName?: string;\n message?: string;\n exitCode?: number;\n signal?: string;\n stderrTail?: string;\n /**\n * What memory looked like when the death was observed, taken by the parent.\n *\n * Without it a marker reading `worker-exit` with exit code 1 is\n * indistinguishable from an ordinary application error, and a reader who\n * starts here would classify an exhausted machine as a bug in the code that\n * happened to be running.\n */\n memoryAtDeath?: {\n /** Resident size of the parent process, which hosts the dying worker. */\n rss: number;\n /** Highest resident size the kernel recorded for it. */\n maxRss: number;\n freeMemory: number;\n totalMemory: number;\n };\n};\n\nexport type CrashReason =\n | \"js-heap-out-of-memory\"\n | \"killed-by-os\"\n | \"abort-or-fatal-allocation-failure\"\n | \"worker-exit\"\n | \"nonzero-exit\"\n | \"unknown\";\n\nexport const SESSION_RECORD = \"session\";\n/** Earliest memory reading of a session, rewritten into every rotated segment. */\nexport const MEM_BASELINE_RECORD = \"mem-baseline\";\nexport const SESSION_END_RECORD = \"session-end\";\nexport const HOST_FILE_PREFIX = \"host\";\nexport const SESSION_FILE_PREFIX = \"session\";\nexport const SAMPLER_FILE_PREFIX = \"memory\";\nexport const DEATH_FILE_PREFIX = \"death\";\n"],"mappings":";AA8JA,MAAa,iBAAiB;;AAE9B,MAAa,sBAAsB;AACnC,MAAa,qBAAqB;AAClC,MAAa,mBAAmB;AAChC,MAAa,sBAAsB;AACnC,MAAa,sBAAsB;AACnC,MAAa,oBAAoB"}
@@ -0,0 +1,35 @@
1
+ import { HostRecord } from "./events.js";
2
+ //#region src/host_sampler.d.ts
3
+ export type HostReading = Omit<HostRecord, "seq" | "t" | "wall" | "type">;
4
+ export type HostSamplerOptions = {
5
+ dir: string;
6
+ sessionId: string;
7
+ /**
8
+ * Takes one reading. Supplied by the caller because the useful numbers come
9
+ * from the application framework rather than from Node, and this package must
10
+ * not depend on it.
11
+ */
12
+ read: () => HostReading | Promise<HostReading>;
13
+ /** Sampling period; these readings change slowly and cost more than resident size. */
14
+ intervalMs?: number;
15
+ };
16
+ export type HostSampler = {
17
+ /** Sibling log this sampler appends to. */
18
+ readonly file: string;
19
+ stop(): void;
20
+ };
21
+ /**
22
+ * Records what only the host process can measure, beside the session it hosts.
23
+ *
24
+ * Its own file rather than the session log: that log belongs to another thread
25
+ * and has its own descriptor and sequence, and two writers sharing them would
26
+ * corrupt both. Readers join the two by wall clock, as they already do with the
27
+ * memory sampler.
28
+ *
29
+ * This one runs wherever the caller runs, so a blocked host stops it — which is
30
+ * why it supplements the sampler thread rather than replacing it. What it adds
31
+ * is attribution, and attribution a second stale is still attribution.
32
+ */
33
+ export declare function startHostSampler(options: HostSamplerOptions): HostSampler;
34
+ //#endregion
35
+ //# sourceMappingURL=host_sampler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host_sampler.d.ts","names":[],"sources":["../src/host_sampler.ts"],"mappings":";;YAIY,cAAc,KAAK;YAEnB;EACV;EACA;;;;;;EAMA,YAAY,cAAc,QAAQ;;EAElC;;YAGU;;WAED;EACT;;;;;;;;;;;;;;wBAec,iBAAiB,SAAS,qBAAqB"}
@@ -0,0 +1,54 @@
1
+ import { HOST_FILE_PREFIX } from "./events.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ //#region src/host_sampler.ts
5
+ /**
6
+ * Records what only the host process can measure, beside the session it hosts.
7
+ *
8
+ * Its own file rather than the session log: that log belongs to another thread
9
+ * and has its own descriptor and sequence, and two writers sharing them would
10
+ * corrupt both. Readers join the two by wall clock, as they already do with the
11
+ * memory sampler.
12
+ *
13
+ * This one runs wherever the caller runs, so a blocked host stops it — which is
14
+ * why it supplements the sampler thread rather than replacing it. What it adds
15
+ * is attribution, and attribution a second stale is still attribution.
16
+ */
17
+ function startHostSampler(options) {
18
+ const { dir, sessionId, read, intervalMs = 1e3 } = options;
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ const file = path.join(dir, `${HOST_FILE_PREFIX}-${sessionId}.ndjson`);
21
+ const fd = fs.openSync(file, "a");
22
+ let seq = 0;
23
+ let writing = false;
24
+ const timer = setInterval(() => {
25
+ if (writing) return;
26
+ writing = true;
27
+ Promise.resolve().then(read).then((reading) => {
28
+ const record = {
29
+ seq: ++seq,
30
+ t: Math.round(performance.now() * 1e3) / 1e3,
31
+ wall: Date.now(),
32
+ type: "mem-host",
33
+ ...reading
34
+ };
35
+ fs.writeSync(fd, `${JSON.stringify(record)}\n`);
36
+ }).catch(() => {}).finally(() => {
37
+ writing = false;
38
+ });
39
+ }, intervalMs);
40
+ timer.unref();
41
+ return {
42
+ file,
43
+ stop: () => {
44
+ clearInterval(timer);
45
+ try {
46
+ fs.closeSync(fd);
47
+ } catch {}
48
+ }
49
+ };
50
+ }
51
+ //#endregion
52
+ export { startHostSampler };
53
+
54
+ //# sourceMappingURL=host_sampler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host_sampler.js","names":[],"sources":["../src/host_sampler.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { HOST_FILE_PREFIX, type HostRecord } from \"./events\";\n\nexport type HostReading = Omit<HostRecord, \"seq\" | \"t\" | \"wall\" | \"type\">;\n\nexport type HostSamplerOptions = {\n dir: string;\n sessionId: string;\n /**\n * Takes one reading. Supplied by the caller because the useful numbers come\n * from the application framework rather than from Node, and this package must\n * not depend on it.\n */\n read: () => HostReading | Promise<HostReading>;\n /** Sampling period; these readings change slowly and cost more than resident size. */\n intervalMs?: number;\n};\n\nexport type HostSampler = {\n /** Sibling log this sampler appends to. */\n readonly file: string;\n stop(): void;\n};\n\n/**\n * Records what only the host process can measure, beside the session it hosts.\n *\n * Its own file rather than the session log: that log belongs to another thread\n * and has its own descriptor and sequence, and two writers sharing them would\n * corrupt both. Readers join the two by wall clock, as they already do with the\n * memory sampler.\n *\n * This one runs wherever the caller runs, so a blocked host stops it — which is\n * why it supplements the sampler thread rather than replacing it. What it adds\n * is attribution, and attribution a second stale is still attribution.\n */\nexport function startHostSampler(options: HostSamplerOptions): HostSampler {\n const { dir, sessionId, read, intervalMs = 1000 } = options;\n fs.mkdirSync(dir, { recursive: true });\n const file = path.join(dir, `${HOST_FILE_PREFIX}-${sessionId}.ndjson`);\n const fd = fs.openSync(file, \"a\");\n let seq = 0;\n let writing = false;\n\n const timer = setInterval(() => {\n // A reading that outlives its interval must not queue up behind itself.\n if (writing) return;\n writing = true;\n void Promise.resolve()\n .then(read)\n .then((reading) => {\n const record: HostRecord = {\n seq: ++seq,\n t: Math.round(performance.now() * 1000) / 1000,\n wall: Date.now(),\n type: \"mem-host\",\n ...reading,\n };\n fs.writeSync(fd, `${JSON.stringify(record)}\\n`);\n })\n .catch(() => {\n // Sampling must never take the application down.\n })\n .finally(() => {\n writing = false;\n });\n }, intervalMs);\n // Unreferenced so a sampler that is never stopped cannot hold the host open.\n timer.unref();\n\n return {\n file,\n stop: () => {\n clearInterval(timer);\n try {\n fs.closeSync(fd);\n } catch {\n // Closing an already-dead descriptor must not fail shutdown.\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqCA,SAAgB,iBAAiB,SAA0C;CACzE,MAAM,EAAE,KAAK,WAAW,MAAM,aAAa,QAAS;CACpD,GAAG,UAAU,KAAK,EAAE,WAAW,KAAK,CAAC;CACrC,MAAM,OAAO,KAAK,KAAK,KAAK,GAAG,iBAAiB,GAAG,UAAU,QAAQ;CACrE,MAAM,KAAK,GAAG,SAAS,MAAM,GAAG;CAChC,IAAI,MAAM;CACV,IAAI,UAAU;CAEd,MAAM,QAAQ,kBAAkB;EAE9B,IAAI,SAAS;EACb,UAAU;EACV,QAAa,QAAQ,CAAC,CACnB,KAAK,IAAI,CAAC,CACV,MAAM,YAAY;GACjB,MAAM,SAAqB;IACzB,KAAK,EAAE;IACP,GAAG,KAAK,MAAM,YAAY,IAAI,IAAI,GAAI,IAAI;IAC1C,MAAM,KAAK,IAAI;IACf,MAAM;IACN,GAAG;GACL;GACA,GAAG,UAAU,IAAI,GAAG,KAAK,UAAU,MAAM,EAAE,GAAG;EAChD,CAAC,CAAC,CACD,YAAY,CAEb,CAAC,CAAC,CACD,cAAc;GACb,UAAU;EACZ,CAAC;CACL,GAAG,UAAU;CAEb,MAAM,MAAM;CAEZ,OAAO;EACL;EACA,YAAY;GACV,cAAc,KAAK;GACnB,IAAI;IACF,GAAG,UAAU,EAAE;GACjB,QAAQ,CAER;EACF;CACF;AACF"}
@@ -0,0 +1,7 @@
1
+ import { CrashMarker, CrashReason, HostRecord, LogRecord, MachineMemory, MemorySnapshot, SamplerRecord, SessionEnvironment } from "./events.js";
2
+ import { Recorder, RecorderOptions, SessionFileInfo, listSessions, newSessionId, openRecorder, sessionIdFromFile } from "./recorder.js";
3
+ import { HandleRegistry, RenderInfo, createHandleRegistry, recordModelRenderSync, wrapDataDriver, wrapModelDriver } from "./instrument.js";
4
+ import { CRASH_DIR_ENV, CRASH_SESSION_ENV, RecordingSession, RecordingSessionOptions, openRecordingSession } from "./session.js";
5
+ import { HostReading, HostSampler, HostSamplerOptions, startHostSampler } from "./host_sampler.js";
6
+ import { SuperviseOptions, SupervisedWorker, readCrashMarkers, superviseWorker } from "./supervisor.js";
7
+ export { CRASH_DIR_ENV, CRASH_SESSION_ENV, type CrashMarker, type CrashReason, type HandleRegistry, type HostReading, type HostRecord, type HostSampler, type HostSamplerOptions, type LogRecord, type MachineMemory, type MemorySnapshot, type Recorder, type RecorderOptions, type RecordingSession, type RecordingSessionOptions, type RenderInfo, type SamplerRecord, type SessionEnvironment, type SessionFileInfo, type SuperviseOptions, type SupervisedWorker, createHandleRegistry, listSessions, newSessionId, openRecorder, openRecordingSession, readCrashMarkers, recordModelRenderSync, sessionIdFromFile, startHostSampler, superviseWorker, wrapDataDriver, wrapModelDriver };
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import { listSessions, newSessionId, openRecorder, sessionIdFromFile } from "./recorder.js";
2
+ import { createHandleRegistry, recordModelRenderSync, wrapDataDriver, wrapModelDriver } from "./instrument.js";
3
+ import { CRASH_DIR_ENV, CRASH_SESSION_ENV, openRecordingSession } from "./session.js";
4
+ import { startHostSampler } from "./host_sampler.js";
5
+ import { readCrashMarkers, superviseWorker } from "./supervisor.js";
6
+ export { CRASH_DIR_ENV, CRASH_SESSION_ENV, createHandleRegistry, listSessions, newSessionId, openRecorder, openRecordingSession, readCrashMarkers, recordModelRenderSync, sessionIdFromFile, startHostSampler, superviseWorker, wrapDataDriver, wrapModelDriver };
@@ -0,0 +1,79 @@
1
+ import { Recorder } from "./recorder.js";
2
+ //#region src/instrument.d.ts
3
+ /**
4
+ * Wrappers for the seams the model layer passes through.
5
+ *
6
+ * Every operation writes a begin record and an end record. That pairing is what
7
+ * makes a crash legible: when the process dies mid-operation the end record is
8
+ * missing, so the log names the exact call that was running when memory ran out
9
+ * — the question a post-crash report has to answer.
10
+ *
11
+ * The wrappers are structural rather than tied to one driver interface, because
12
+ * the same three creation methods appear twice with different return types: the
13
+ * model-facing driver hands back a bare handle, the internal one hands back a
14
+ * pool entry.
15
+ */
16
+ type HandleOrigin = {
17
+ /** Sequence number of the record holding the definition. */
18
+ seq: number;
19
+ op: string;
20
+ observed?: {
21
+ rows?: number;
22
+ columns?: number;
23
+ };
24
+ };
25
+ export type HandleRegistry = {
26
+ put(handle: string, origin: HandleOrigin): void;
27
+ get(handle: string): HandleOrigin | undefined;
28
+ observe(handle: string, observed: {
29
+ rows?: number;
30
+ columns?: number;
31
+ }): void;
32
+ };
33
+ type ModelDriverLike<H> = {
34
+ createPFrame(def: never): H;
35
+ createPTable(def: never): H;
36
+ createPTableV2(def: never): H;
37
+ };
38
+ export type RenderInfo = {
39
+ blockId?: string;
40
+ /** What the block is, as `organization:name` — not the id it has in a project. */
41
+ block?: string;
42
+ blockVersion?: string;
43
+ /** Where the block came from: a registry, a local pack, a dev folder. */
44
+ blockSource?: string;
45
+ /** SDK the block's model was built against. */
46
+ sdkVersion?: string;
47
+ key?: string;
48
+ argsHash?: string;
49
+ /** Which lambda of the block's model is being rendered. */
50
+ lambda?: string;
51
+ /** Nth resumption of a deferred render, counted from one. */
52
+ recalculation?: number;
53
+ /** Read after the render, so sandbox counters cover the whole call. */
54
+ getStats?: () => unknown;
55
+ /** Any further context the call site wants on the record. */
56
+ [key: string]: unknown;
57
+ };
58
+ /** Maps driver handles back to the join that produced them. */
59
+ export declare function createHandleRegistry(limit?: number): HandleRegistry;
60
+ /**
61
+ * Wraps the driver that block models call to build frames and tables.
62
+ *
63
+ * Records the redacted join tree and its structural findings, then remembers
64
+ * which handle came from which join so later data calls can be attributed back
65
+ * to the definition that caused them.
66
+ */
67
+ export declare function wrapModelDriver<D extends ModelDriverLike<unknown>>(driver: D, recorder: Recorder, registry: HandleRegistry, handleOf?: (result: unknown) => string): D;
68
+ /**
69
+ * Wraps the asynchronous data-access driver.
70
+ *
71
+ * Adds the observed table shape, the size of what crossed back into JavaScript,
72
+ * and the amplification between input and output rows — the empirical
73
+ * counterpart to the structural findings taken from the join tree.
74
+ */
75
+ export declare function wrapDataDriver<D extends object>(driver: D, recorder: Recorder, registry: HandleRegistry): D;
76
+ /** Synchronous variant, for a render that is not driven by a promise. */
77
+ export declare function recordModelRenderSync<T>(recorder: Recorder | undefined, info: RenderInfo, fn: () => T): T;
78
+ //#endregion
79
+ //# sourceMappingURL=instrument.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instrument.d.ts","names":[],"sources":["../src/instrument.ts"],"mappings":";;;;;;;;;;;;;;;KAmBK;;EAEH;EACA;EACA;IAAa;IAAe;;;YAGlB;EACV,IAAI,gBAAgB,QAAQ;EAC5B,IAAI,iBAAiB;EACrB,QAAQ,gBAAgB;IAAY;IAAe;;;KAGhD,gBAAgB;EACnB,aAAa,aAAa;EAC1B,aAAa,aAAa;EAC1B,eAAe,aAAa;;YAGlB;EACV;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;;EAEA;;EAEA;;EAEA;;GAEC;;;wBAIa,qBAAqB,iBAAc;;;;;;;;wBA2BnC,gBAAgB,UAAU,0BACxC,QAAQ,GACR,UAAU,UACV,UAAU,gBACV,YAAW,6BACV;;;;;;;;wBA8Ba,eAAe,kBAC7B,QAAQ,GACR,UAAU,UACV,UAAU,iBACT;;wBAgKa,sBAAsB,GACpC,UAAU,sBACV,MAAM,YACN,UAAU,IACT"}