@milaboratories/pl-flight-recorder 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/dist/analyze.d.ts +133 -0
- package/dist/analyze.d.ts.map +1 -0
- package/dist/analyze.js +525 -0
- package/dist/analyze.js.map +1 -0
- package/dist/data_summary.d.ts +28 -0
- package/dist/data_summary.d.ts.map +1 -0
- package/dist/data_summary.js +73 -0
- package/dist/data_summary.js.map +1 -0
- package/dist/digest.d.ts +28 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +55 -0
- package/dist/digest.js.map +1 -0
- package/dist/events.d.ts +89 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +12 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/instrument.d.ts +82 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +313 -0
- package/dist/instrument.js.map +1 -0
- package/dist/recorder.d.ts +82 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +293 -0
- package/dist/recorder.js.map +1 -0
- package/dist/redact.d.ts +53 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +145 -0
- package/dist/redact.js.map +1 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +377 -0
- package/dist/report.js.map +1 -0
- package/dist/rules.d.ts +73 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +245 -0
- package/dist/rules.js.map +1 -0
- package/dist/sampler.d.ts +22 -0
- package/dist/sampler.d.ts.map +1 -0
- package/dist/sampler.js +33 -0
- package/dist/sampler.js.map +1 -0
- package/dist/sampler_thread.d.ts +1 -0
- package/dist/sampler_thread.js +41 -0
- package/dist/sampler_thread.js.map +1 -0
- package/dist/session.d.ts +40 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/supervisor.d.ts +58 -0
- package/dist/supervisor.d.ts.map +1 -0
- package/dist/supervisor.js +108 -0
- package/dist/supervisor.js.map +1 -0
- package/package.json +43 -0
- package/src/analyze.test.ts +539 -0
- package/src/analyze.ts +795 -0
- package/src/data_summary.ts +110 -0
- package/src/digest.ts +49 -0
- package/src/events.ts +102 -0
- package/src/index.ts +104 -0
- package/src/instrument.ts +442 -0
- package/src/recorder.ts +397 -0
- package/src/redact.test.ts +155 -0
- package/src/redact.ts +213 -0
- package/src/report.ts +512 -0
- package/src/rules.test.ts +182 -0
- package/src/rules.ts +383 -0
- package/src/sampler.ts +40 -0
- package/src/sampler_thread.ts +45 -0
- package/src/session.ts +69 -0
- package/src/supervisor.ts +150 -0
package/src/rules.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JoinEntry,
|
|
3
|
+
SpecQueryLinkerJoin,
|
|
4
|
+
SpecQueryOuterJoin,
|
|
5
|
+
SpecQuerySymmetricJoin,
|
|
6
|
+
} from "@milaboratories/pl-model-common";
|
|
7
|
+
import type { DataSummary } from "./data_summary";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Structural faults in a join, read from the recorded shape of its definition.
|
|
11
|
+
*
|
|
12
|
+
* Two faults make a join's output size unbounded and both are visible before any
|
|
13
|
+
* data is touched: siblings that share no axis at all, which is a cartesian
|
|
14
|
+
* product, and siblings whose axes agree on name and type but disagree on
|
|
15
|
+
* domain, where the join key silently fails to match.
|
|
16
|
+
*
|
|
17
|
+
* These run in the analyzer rather than at record time. Nothing is computed on
|
|
18
|
+
* the hot path, the thresholds and the rules can be revised against logs that
|
|
19
|
+
* already exist, and one implementation covers every definition shape: the join
|
|
20
|
+
* nodes are recognised by their discriminator and their children by position, so
|
|
21
|
+
* both the original tree API and the V2 query API are read by the same walk.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type FindingSeverity = "critical" | "high" | "medium" | "low";
|
|
25
|
+
|
|
26
|
+
export type StructuralFinding = {
|
|
27
|
+
rule: "cross-join" | "axis-domain-mismatch" | "partial-key-fan-out";
|
|
28
|
+
severity: FindingSeverity;
|
|
29
|
+
/** Position in the definition, e.g. `root/innerJoin[1]`. */
|
|
30
|
+
path: string;
|
|
31
|
+
join: string;
|
|
32
|
+
detail: string;
|
|
33
|
+
rowsUpperBound?: number;
|
|
34
|
+
domains?: { domain: string; children: number[] }[];
|
|
35
|
+
missing?: { index: number; missing: string[] }[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type AxisDescriptor = {
|
|
39
|
+
name: string;
|
|
40
|
+
type: string;
|
|
41
|
+
domain?: Record<string, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type JoinShape = {
|
|
45
|
+
join: string;
|
|
46
|
+
path: string;
|
|
47
|
+
childCount: number;
|
|
48
|
+
axisUnion: string[];
|
|
49
|
+
sharedAxes: string[];
|
|
50
|
+
disjointPairs: [number, number][];
|
|
51
|
+
inputRowsMax?: number;
|
|
52
|
+
/** Loose but true: no join of these inputs can exceed the product of their rows. */
|
|
53
|
+
rowsUpperBound?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// The discriminators are pinned to the model's own literal types, so renaming a
|
|
57
|
+
// join kind in pl-model-common fails this build instead of silently disabling a
|
|
58
|
+
// rule. The model exports the names only as types, never as runtime constants.
|
|
59
|
+
type TreeJoinType = Extract<
|
|
60
|
+
JoinEntry<unknown>,
|
|
61
|
+
{ entries: unknown } | { primary: unknown }
|
|
62
|
+
>["type"];
|
|
63
|
+
type QueryJoinType = (SpecQuerySymmetricJoin | SpecQueryOuterJoin | SpecQueryLinkerJoin)["type"];
|
|
64
|
+
|
|
65
|
+
/** Joins that keep only keys present in every entry; an entry missing part of the key fans out. */
|
|
66
|
+
const INTERSECT_JOINS: ReadonlySet<string> = new Set(["inner", "innerJoin"] satisfies (
|
|
67
|
+
| TreeJoinType
|
|
68
|
+
| QueryJoinType
|
|
69
|
+
)[]);
|
|
70
|
+
/** Joins that keep keys present in any entry, filling the rest with nulls. */
|
|
71
|
+
const UNION_JOINS: ReadonlySet<string> = new Set(["full", "fullJoin"] satisfies (
|
|
72
|
+
| TreeJoinType
|
|
73
|
+
| QueryJoinType
|
|
74
|
+
)[]);
|
|
75
|
+
/** Joins driven by one side: the primary or linker decides which keys exist. */
|
|
76
|
+
const DRIVEN_JOINS: ReadonlySet<string> = new Set(["outer", "outerJoin", "linkerJoin"] satisfies (
|
|
77
|
+
| TreeJoinType
|
|
78
|
+
| QueryJoinType
|
|
79
|
+
)[]);
|
|
80
|
+
|
|
81
|
+
/** Structural findings for a recorded definition, most specific first. */
|
|
82
|
+
export function structuralFindings(def: unknown): StructuralFinding[] {
|
|
83
|
+
const findings: StructuralFinding[] = [];
|
|
84
|
+
visit(def, "root", (node, path) => collect(node, path, findings));
|
|
85
|
+
return findings;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Shape of every join node in a definition, outermost first. */
|
|
89
|
+
export function joinShapes(def: unknown): JoinShape[] {
|
|
90
|
+
const shapes: JoinShape[] = [];
|
|
91
|
+
visit(def, "root", (node, path) => shapes.push(shapeOf(node, path)));
|
|
92
|
+
return shapes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Largest input row count the definition declares, used to judge how much a
|
|
97
|
+
* join amplified. Unknown when no workflow recorded chunk statistics.
|
|
98
|
+
*/
|
|
99
|
+
export function inputRowsMax(def: unknown): number | undefined {
|
|
100
|
+
const rows = childRowCounts(def);
|
|
101
|
+
return rows.length > 0 ? Math.max(...rows) : estimateRows(def);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Canonical axis identity, matching how join keys are formed. */
|
|
105
|
+
export function axisKey(axis: AxisDescriptor): string {
|
|
106
|
+
return `${axis.type}|${axis.name}|${canonicalDomain(axis.domain)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Axis identity ignoring domain, used to spot near-miss axes that fail to join. */
|
|
110
|
+
export function axisNameKey(axis: AxisDescriptor): string {
|
|
111
|
+
return `${axis.type}|${axis.name}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when a node is a join, by its discriminator. */
|
|
115
|
+
export function isJoinNode(node: unknown): boolean {
|
|
116
|
+
const type = discriminator(node);
|
|
117
|
+
return (
|
|
118
|
+
type !== undefined &&
|
|
119
|
+
(INTERSECT_JOINS.has(type) || UNION_JOINS.has(type) || DRIVEN_JOINS.has(type))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A join's children, by position. The V2 API wraps each child in `{ entry }`;
|
|
125
|
+
* that wrapper is left in place because every read here descends through it.
|
|
126
|
+
*/
|
|
127
|
+
export function joinChildren(node: unknown): unknown[] {
|
|
128
|
+
const record = asRecord(node);
|
|
129
|
+
if (!record) return [];
|
|
130
|
+
if (Array.isArray(record.entries)) return record.entries;
|
|
131
|
+
const driven = [record.primary ?? record.linker, ...toArray(record.secondary)];
|
|
132
|
+
return driven.filter((child) => child !== undefined && child !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Axis descriptors anywhere beneath a node, deduplicated by identity. */
|
|
136
|
+
export function axesUnder(node: unknown): AxisDescriptor[] {
|
|
137
|
+
const out = new Map<string, AxisDescriptor>();
|
|
138
|
+
gatherAxes(node, out, 0);
|
|
139
|
+
return [...out.values()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Internals
|
|
143
|
+
|
|
144
|
+
const MAX_WALK_DEPTH = 40;
|
|
145
|
+
|
|
146
|
+
function visit(
|
|
147
|
+
node: unknown,
|
|
148
|
+
path: string,
|
|
149
|
+
onJoin: (node: unknown, path: string) => void,
|
|
150
|
+
depth = 0,
|
|
151
|
+
): void {
|
|
152
|
+
if (depth > MAX_WALK_DEPTH || !isTraversable(node)) return;
|
|
153
|
+
if (isJoinNode(node)) {
|
|
154
|
+
onJoin(node, path);
|
|
155
|
+
const join = discriminator(node) ?? "join";
|
|
156
|
+
for (const [index, child] of joinChildren(node).entries()) {
|
|
157
|
+
visit(child, `${path}/${join}[${index}]`, onJoin, depth + 1);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const child of childValues(node)) visit(child, path, onJoin, depth + 1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function shapeOf(node: unknown, path: string): JoinShape {
|
|
165
|
+
const join = discriminator(node) ?? "join";
|
|
166
|
+
const children = joinChildren(node);
|
|
167
|
+
const keySets = children.map((child) => new Set(axesUnder(child).map(axisKey)));
|
|
168
|
+
|
|
169
|
+
const union = new Set<string>();
|
|
170
|
+
for (const set of keySets) for (const key of set) union.add(key);
|
|
171
|
+
|
|
172
|
+
const disjointPairs: [number, number][] = [];
|
|
173
|
+
for (let i = 0; i < keySets.length; i++) {
|
|
174
|
+
for (let j = i + 1; j < keySets.length; j++) {
|
|
175
|
+
if (keySets[i].size === 0 || keySets[j].size === 0) continue;
|
|
176
|
+
if ([...keySets[i]].some((key) => keySets[j].has(key))) continue;
|
|
177
|
+
disjointPairs.push([i, j]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rows = children.map(estimateRows);
|
|
182
|
+
const known = rows.filter((value): value is number => typeof value === "number");
|
|
183
|
+
return {
|
|
184
|
+
join,
|
|
185
|
+
path,
|
|
186
|
+
childCount: children.length,
|
|
187
|
+
axisUnion: [...union],
|
|
188
|
+
sharedAxes: [...union].filter((key) => keySets.every((set) => set.has(key))),
|
|
189
|
+
disjointPairs,
|
|
190
|
+
inputRowsMax: known.length > 0 ? Math.max(...known) : undefined,
|
|
191
|
+
rowsUpperBound: known.length === rows.length && known.length > 0 ? product(known) : undefined,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function collect(node: unknown, path: string, findings: StructuralFinding[]): void {
|
|
196
|
+
const shape = shapeOf(node, path);
|
|
197
|
+
const children = joinChildren(node);
|
|
198
|
+
|
|
199
|
+
if (shape.disjointPairs.length > 0) {
|
|
200
|
+
findings.push({
|
|
201
|
+
rule: "cross-join",
|
|
202
|
+
severity: "critical",
|
|
203
|
+
path,
|
|
204
|
+
join: shape.join,
|
|
205
|
+
detail: `join siblings share no axis: pairs ${JSON.stringify(shape.disjointPairs)}`,
|
|
206
|
+
rowsUpperBound: shape.rowsUpperBound,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const nearMiss of nearMissAxes(children)) {
|
|
211
|
+
findings.push({
|
|
212
|
+
rule: "axis-domain-mismatch",
|
|
213
|
+
severity: "high",
|
|
214
|
+
path,
|
|
215
|
+
join: shape.join,
|
|
216
|
+
detail: `axis ${nearMiss.axis} appears with ${nearMiss.domains.length} different domains`,
|
|
217
|
+
domains: nearMiss.domains,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Fan-out is worth reporting only where the node still has a working join key
|
|
222
|
+
// and its entries are peers; on a cartesian node it restates the cross-join,
|
|
223
|
+
// and on a driven join a narrower secondary is the intended behaviour.
|
|
224
|
+
if (!INTERSECT_JOINS.has(shape.join) || shape.disjointPairs.length > 0) return;
|
|
225
|
+
const missing = children
|
|
226
|
+
.map((child, index) => {
|
|
227
|
+
const own = new Set(axesUnder(child).map(axisKey));
|
|
228
|
+
return { index, missing: shape.axisUnion.filter((key) => !own.has(key)) };
|
|
229
|
+
})
|
|
230
|
+
.filter((entry) => entry.missing.length > 0);
|
|
231
|
+
if (missing.length === 0) return;
|
|
232
|
+
findings.push({
|
|
233
|
+
rule: "partial-key-fan-out",
|
|
234
|
+
severity: "medium",
|
|
235
|
+
path,
|
|
236
|
+
join: shape.join,
|
|
237
|
+
detail: `${missing.length} sibling(s) lack part of the node's axis union and get replicated`,
|
|
238
|
+
missing,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Axes agreeing on name and type but disagreeing on domain never match as a
|
|
243
|
+
// join key, which turns an intended join into a product or an empty result.
|
|
244
|
+
function nearMissAxes(
|
|
245
|
+
children: unknown[],
|
|
246
|
+
): { axis: string; domains: { domain: string; children: number[] }[] }[] {
|
|
247
|
+
const byName = new Map<string, Map<string, Set<number>>>();
|
|
248
|
+
for (const [index, child] of children.entries()) {
|
|
249
|
+
for (const axis of axesUnder(child)) {
|
|
250
|
+
const nameKey = axisNameKey(axis);
|
|
251
|
+
let perDomain = byName.get(nameKey);
|
|
252
|
+
if (!perDomain) byName.set(nameKey, (perDomain = new Map()));
|
|
253
|
+
const domainKey = canonicalDomain(axis.domain);
|
|
254
|
+
let indices = perDomain.get(domainKey);
|
|
255
|
+
if (!indices) perDomain.set(domainKey, (indices = new Set()));
|
|
256
|
+
indices.add(index);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const out: { axis: string; domains: { domain: string; children: number[] }[] }[] = [];
|
|
260
|
+
for (const [axis, perDomain] of byName) {
|
|
261
|
+
if (perDomain.size < 2) continue;
|
|
262
|
+
out.push({
|
|
263
|
+
axis,
|
|
264
|
+
domains: [...perDomain.entries()].map(([domain, indices]) => ({
|
|
265
|
+
domain: domain || "(none)",
|
|
266
|
+
children: [...indices],
|
|
267
|
+
})),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function estimateRows(node: unknown, depth = 0): number | undefined {
|
|
274
|
+
if (depth > MAX_WALK_DEPTH || !isTraversable(node)) return undefined;
|
|
275
|
+
if (isJoinNode(node)) {
|
|
276
|
+
const join = discriminator(node) ?? "";
|
|
277
|
+
const rows = joinChildren(node)
|
|
278
|
+
.map((child) => estimateRows(child, depth + 1))
|
|
279
|
+
.filter((value): value is number => typeof value === "number");
|
|
280
|
+
if (rows.length === 0) return undefined;
|
|
281
|
+
// An intersection cannot exceed its largest input; a union adds up.
|
|
282
|
+
return INTERSECT_JOINS.has(join) ? Math.max(...rows) : sum(rows);
|
|
283
|
+
}
|
|
284
|
+
const own = ownRows(node);
|
|
285
|
+
if (own !== undefined) return own;
|
|
286
|
+
const rows = childValues(node)
|
|
287
|
+
.map((child) => estimateRows(child, depth + 1))
|
|
288
|
+
.filter((value): value is number => typeof value === "number");
|
|
289
|
+
return rows.length > 0 ? Math.max(...rows) : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function childRowCounts(def: unknown): number[] {
|
|
293
|
+
const shapes = joinShapes(def);
|
|
294
|
+
return shapes
|
|
295
|
+
.map((shape) => shape.inputRowsMax)
|
|
296
|
+
.filter((value): value is number => typeof value === "number");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function ownRows(node: unknown): number | undefined {
|
|
300
|
+
const record = asRecord(node);
|
|
301
|
+
if (!record) return undefined;
|
|
302
|
+
for (const key of ["data", "dataInfo"]) {
|
|
303
|
+
const summary = record[key] as DataSummary | undefined;
|
|
304
|
+
if (!summary || typeof summary !== "object") continue;
|
|
305
|
+
if (typeof summary.rows === "number") return summary.rows;
|
|
306
|
+
if (typeof summary.entries === "number") return summary.entries;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function gatherAxes(node: unknown, out: Map<string, AxisDescriptor>, depth: number): void {
|
|
312
|
+
if (depth > MAX_WALK_DEPTH || !isTraversable(node)) return;
|
|
313
|
+
const record = asRecord(node);
|
|
314
|
+
if (record) {
|
|
315
|
+
for (const key of ["axesSpec", "axes"]) {
|
|
316
|
+
const value = record[key];
|
|
317
|
+
if (!Array.isArray(value)) continue;
|
|
318
|
+
for (const item of value) {
|
|
319
|
+
const axis = asAxis(item);
|
|
320
|
+
if (axis) out.set(axisKey(axis), axis);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const child of childValues(node)) gatherAxes(child, out, depth + 1);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function asAxis(value: unknown): AxisDescriptor | undefined {
|
|
328
|
+
const record = asRecord(value);
|
|
329
|
+
if (!record) return undefined;
|
|
330
|
+
const { name, type, domain } = record;
|
|
331
|
+
if (typeof name !== "string" || typeof type !== "string") return undefined;
|
|
332
|
+
return { name, type, domain: plainStringMap(domain) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function plainStringMap(value: unknown): Record<string, string> | undefined {
|
|
336
|
+
const record = asRecord(value);
|
|
337
|
+
if (!record) return undefined;
|
|
338
|
+
const out: Record<string, string> = {};
|
|
339
|
+
for (const [key, item] of Object.entries(record)) {
|
|
340
|
+
if (typeof item === "string") out[key] = item;
|
|
341
|
+
}
|
|
342
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function canonicalDomain(domain: Record<string, string> | undefined): string {
|
|
346
|
+
return Object.entries(domain ?? {})
|
|
347
|
+
.sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1))
|
|
348
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
349
|
+
.join(",");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function discriminator(node: unknown): string | undefined {
|
|
353
|
+
const type = asRecord(node)?.type;
|
|
354
|
+
return typeof type === "string" ? type : undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function childValues(node: unknown): unknown[] {
|
|
358
|
+
if (Array.isArray(node)) return node;
|
|
359
|
+
const record = asRecord(node);
|
|
360
|
+
return record ? Object.values(record) : [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
364
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
365
|
+
? (value as Record<string, unknown>)
|
|
366
|
+
: undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isTraversable(value: unknown): boolean {
|
|
370
|
+
return typeof value === "object" && value !== null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function toArray(value: unknown): unknown[] {
|
|
374
|
+
return Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function sum(values: number[]): number {
|
|
378
|
+
return values.reduce((acc, value) => acc + value, 0);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function product(values: number[]): number {
|
|
382
|
+
return values.reduce((acc, value) => acc * value, 1);
|
|
383
|
+
}
|
package/src/sampler.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Worker } from "node:worker_threads";
|
|
3
|
+
import { SAMPLER_FILE_PREFIX } from "./events";
|
|
4
|
+
|
|
5
|
+
export type MemorySamplerOptions = {
|
|
6
|
+
dir: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
/** Sampling period; 250 ms is roughly four short appends per second. */
|
|
9
|
+
intervalMs?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MemorySampler = {
|
|
13
|
+
/** Sibling log the sampler appends to. */
|
|
14
|
+
readonly file: string;
|
|
15
|
+
stop(): void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Starts the out-of-band memory sampler for a session.
|
|
20
|
+
*
|
|
21
|
+
* The sampler runs on a thread of its own with a small heap of its own, so it
|
|
22
|
+
* keeps producing readings when the observed thread is blocked and when the
|
|
23
|
+
* observed thread's heap is the thing that is full.
|
|
24
|
+
*/
|
|
25
|
+
export function startMemorySampler(options: MemorySamplerOptions): MemorySampler {
|
|
26
|
+
const { dir, sessionId, intervalMs = 250 } = options;
|
|
27
|
+
const file = path.join(dir, `${SAMPLER_FILE_PREFIX}-${sessionId}.ndjson`);
|
|
28
|
+
const worker = new Worker(new URL("./sampler_thread.js", import.meta.url), {
|
|
29
|
+
workerData: { file, intervalMs },
|
|
30
|
+
resourceLimits: { maxOldGenerationSizeMb: 32 },
|
|
31
|
+
});
|
|
32
|
+
// Unreferenced so a sampler that is never stopped cannot hold the process open.
|
|
33
|
+
worker.unref();
|
|
34
|
+
return {
|
|
35
|
+
file,
|
|
36
|
+
stop: () => {
|
|
37
|
+
void worker.terminate();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory sampler, run on its own worker thread.
|
|
3
|
+
*
|
|
4
|
+
* It exists because the thread worth watching is the one that blocks. While the
|
|
5
|
+
* middle layer sits inside a synchronous pframes call its own timers do not
|
|
6
|
+
* fire, so its memory series goes dark exactly while memory is growing fastest.
|
|
7
|
+
* This thread stays responsive and keeps the resident-size curve intact right up
|
|
8
|
+
* to the moment the process dies.
|
|
9
|
+
*
|
|
10
|
+
* `rss` and `freeMemory` are process- and machine-wide and so are meaningful
|
|
11
|
+
* from here. Heap figures are per-isolate and would describe only this thread,
|
|
12
|
+
* so they are deliberately not recorded; the observed thread reports its own.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import { workerData } from "node:worker_threads";
|
|
18
|
+
import type { SamplerRecord } from "./events";
|
|
19
|
+
|
|
20
|
+
type SamplerWorkerData = { file: string; intervalMs: number };
|
|
21
|
+
|
|
22
|
+
const { file, intervalMs } = workerData as SamplerWorkerData;
|
|
23
|
+
const fd = fs.openSync(file, "a");
|
|
24
|
+
let seq = 0;
|
|
25
|
+
let peakRss = 0;
|
|
26
|
+
|
|
27
|
+
setInterval(() => {
|
|
28
|
+
const rss = process.memoryUsage.rss();
|
|
29
|
+
if (rss > peakRss) peakRss = rss;
|
|
30
|
+
const record: SamplerRecord = {
|
|
31
|
+
seq: ++seq,
|
|
32
|
+
t: Math.round(performance.now() * 1000) / 1000,
|
|
33
|
+
wall: Date.now(),
|
|
34
|
+
type: "mem-sampler",
|
|
35
|
+
rss,
|
|
36
|
+
peakRss,
|
|
37
|
+
freeMemory: os.freemem(),
|
|
38
|
+
totalMemory: os.totalmem(),
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
fs.writeSync(fd, `${JSON.stringify(record)}\n`);
|
|
42
|
+
} catch {
|
|
43
|
+
// Sampling must never take the application down.
|
|
44
|
+
}
|
|
45
|
+
}, intervalMs);
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { openRecorder, startSelfSampler, type Recorder } from "./recorder";
|
|
2
|
+
import { startMemorySampler, type MemorySampler } from "./sampler";
|
|
3
|
+
import { createHandleRegistry, type HandleRegistry } from "./instrument";
|
|
4
|
+
|
|
5
|
+
/** Environment variable naming the directory flight logs are written to. */
|
|
6
|
+
export const FLIGHT_DIR_ENV = "MI_FLIGHT_RECORDER_DIR";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Environment variable carrying the session id a supervising parent assigned.
|
|
10
|
+
* Set it alongside {@link FLIGHT_DIR_ENV} when spawning the worker and pass the
|
|
11
|
+
* same id to `superviseWorker`.
|
|
12
|
+
*/
|
|
13
|
+
export const FLIGHT_SESSION_ENV = "MI_FLIGHT_RECORDER_SESSION";
|
|
14
|
+
|
|
15
|
+
export type FlightSessionOptions = {
|
|
16
|
+
/** Overrides the directory from the environment. */
|
|
17
|
+
dir?: string;
|
|
18
|
+
/** Overrides the session id from the environment. */
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
role?: string;
|
|
21
|
+
meta?: Record<string, unknown>;
|
|
22
|
+
samplerIntervalMs?: number;
|
|
23
|
+
selfSamplerIntervalMs?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type FlightSession = {
|
|
27
|
+
readonly recorder: Recorder;
|
|
28
|
+
readonly sampler: MemorySampler;
|
|
29
|
+
/** Shared so create calls and later data calls agree on handle identity. */
|
|
30
|
+
readonly registry: HandleRegistry;
|
|
31
|
+
close(reason?: string): void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Opens a flight session, or returns undefined when recording is not enabled.
|
|
36
|
+
*
|
|
37
|
+
* Recording is opt-in for now: it appends synchronously on every recorded
|
|
38
|
+
* operation, and that cost has not been measured against a real project, so it
|
|
39
|
+
* is switched on by pointing {@link FLIGHT_DIR_ENV} at a directory rather than
|
|
40
|
+
* being on by default.
|
|
41
|
+
*/
|
|
42
|
+
export function openFlightSession(options: FlightSessionOptions = {}): FlightSession | undefined {
|
|
43
|
+
const dir = options.dir ?? process.env[FLIGHT_DIR_ENV];
|
|
44
|
+
if (!dir) return undefined;
|
|
45
|
+
|
|
46
|
+
const recorder = openRecorder({
|
|
47
|
+
dir,
|
|
48
|
+
role: options.role,
|
|
49
|
+
meta: options.meta,
|
|
50
|
+
sessionId: options.sessionId ?? process.env[FLIGHT_SESSION_ENV] ?? undefined,
|
|
51
|
+
});
|
|
52
|
+
const sampler = startMemorySampler({
|
|
53
|
+
dir,
|
|
54
|
+
sessionId: recorder.sessionId,
|
|
55
|
+
intervalMs: options.samplerIntervalMs,
|
|
56
|
+
});
|
|
57
|
+
const stopSelfSampler = startSelfSampler(recorder, options.selfSamplerIntervalMs);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
recorder,
|
|
61
|
+
sampler,
|
|
62
|
+
registry: createHandleRegistry(),
|
|
63
|
+
close(reason) {
|
|
64
|
+
stopSelfSampler();
|
|
65
|
+
sampler.stop();
|
|
66
|
+
recorder.close(reason);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CRASH_FILE_PREFIX, type CrashMarker, type CrashReason } from "./events";
|
|
4
|
+
import { listSessions, sessionIdFromFile } from "./recorder";
|
|
5
|
+
|
|
6
|
+
export type CrashMarkerInput = {
|
|
7
|
+
/** Session id the parent assigned to the worker. Omitted, the marker carries no identity. */
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
reason?: CrashReason;
|
|
10
|
+
error?: (Error & { code?: string }) | unknown;
|
|
11
|
+
code?: number;
|
|
12
|
+
signal?: string;
|
|
13
|
+
stderrTail?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SuperviseOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* The session id handed to the worker at spawn (see `FLIGHT_SESSION_ENV`).
|
|
19
|
+
* With it the marker names the dying session with certainty. Without it the
|
|
20
|
+
* analyzer has to attribute the marker by timing, and will decline to
|
|
21
|
+
* attribute it at all when more than one session looks dead.
|
|
22
|
+
*/
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
onCrash?: (info: {
|
|
25
|
+
kind: "error" | "exit";
|
|
26
|
+
markerFile: string;
|
|
27
|
+
error?: unknown;
|
|
28
|
+
code?: number;
|
|
29
|
+
}) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Minimal view of a worker, so callers are not forced to import worker_threads. */
|
|
33
|
+
export type SupervisedWorker = {
|
|
34
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
35
|
+
on(event: "exit", listener: (code: number) => void): unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Records an abnormal end observed from outside the dying thread.
|
|
40
|
+
*
|
|
41
|
+
* A thread that runs out of heap cannot describe its own death: the last reading
|
|
42
|
+
* it wrote predates the blow-up, and when the blow-up is synchronous no sampler
|
|
43
|
+
* tick of its own lands either. The parent is the only place where the cause is
|
|
44
|
+
* known rather than inferred — Node reports `ERR_WORKER_OUT_OF_MEMORY` to it —
|
|
45
|
+
* so the parent writes the verdict down on the dead thread's behalf.
|
|
46
|
+
*/
|
|
47
|
+
export function writeCrashMarker(dir: string, input: CrashMarkerInput = {}): string {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
const error = input.error as (Error & { code?: string }) | undefined;
|
|
50
|
+
// Only an id the parent handed to the worker is certain, and only a certain
|
|
51
|
+
// id goes in `sessionId`. Reading the newest open flight log names whichever
|
|
52
|
+
// session wrote last, which a concurrent live session makes wrong; recorded
|
|
53
|
+
// as identity that would misattribute the death and, worse, stop the session
|
|
54
|
+
// that actually died from claiming the marker. So it is advisory only.
|
|
55
|
+
const marker: CrashMarker = {
|
|
56
|
+
type: "external-crash",
|
|
57
|
+
wall: Date.now(),
|
|
58
|
+
sessionId: input.sessionId,
|
|
59
|
+
guessedSessionId: input.sessionId === undefined ? newestOpenSessionId(dir) : undefined,
|
|
60
|
+
reason: input.reason ?? classifyReason(input),
|
|
61
|
+
errorCode: error?.code,
|
|
62
|
+
errorName: error?.name,
|
|
63
|
+
message: truncate(String(error?.message ?? input.error ?? ""), 2000),
|
|
64
|
+
exitCode: input.code,
|
|
65
|
+
signal: input.signal,
|
|
66
|
+
stderrTail: truncate(input.stderrTail ?? "", 4000),
|
|
67
|
+
};
|
|
68
|
+
const file = path.join(dir, `${CRASH_FILE_PREFIX}-${marker.wall}.ndjson`);
|
|
69
|
+
fs.writeFileSync(file, `${JSON.stringify(marker)}\n`);
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Crash markers in a directory, oldest first. */
|
|
74
|
+
export function readCrashMarkers(dir: string): CrashMarker[] {
|
|
75
|
+
let names: string[];
|
|
76
|
+
try {
|
|
77
|
+
names = fs.readdirSync(dir);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const markers: CrashMarker[] = [];
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
if (!name.startsWith(`${CRASH_FILE_PREFIX}-`) || !name.endsWith(".ndjson")) continue;
|
|
84
|
+
try {
|
|
85
|
+
const first = fs.readFileSync(path.join(dir, name), "utf8").split("\n")[0];
|
|
86
|
+
markers.push(JSON.parse(first) as CrashMarker);
|
|
87
|
+
} catch {
|
|
88
|
+
// A marker that cannot be parsed is skipped; it is one line of evidence,
|
|
89
|
+
// not the report.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return markers.sort((lhs, rhs) => lhs.wall - rhs.wall);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attaches crash recording to a middle-layer worker thread.
|
|
97
|
+
*
|
|
98
|
+
* A worker whose isolate exhausts its heap dies alone and the parent receives
|
|
99
|
+
* `ERR_WORKER_OUT_OF_MEMORY`, with or without `resourceLimits`. What
|
|
100
|
+
* `resourceLimits.maxOldGenerationSizeMb` adds is a chosen ceiling: V8's default
|
|
101
|
+
* is several gigabytes, so on a small machine the OS can run out of memory and
|
|
102
|
+
* kill the whole process before V8 ever reports the worker's heap as full — and
|
|
103
|
+
* then there is no parent left to write anything.
|
|
104
|
+
*/
|
|
105
|
+
export function superviseWorker(
|
|
106
|
+
worker: SupervisedWorker,
|
|
107
|
+
dir: string,
|
|
108
|
+
options: SuperviseOptions = {},
|
|
109
|
+
): void {
|
|
110
|
+
// One death fires `error` and then `exit`. Only `error` carries the cause, so
|
|
111
|
+
// a later `exit` must not overwrite it with a bare exit code.
|
|
112
|
+
let recorded = false;
|
|
113
|
+
worker.on("error", (error: Error) => {
|
|
114
|
+
recorded = true;
|
|
115
|
+
const markerFile = writeCrashMarker(dir, { error, sessionId: options.sessionId });
|
|
116
|
+
options.onCrash?.({ kind: "error", error, markerFile });
|
|
117
|
+
});
|
|
118
|
+
worker.on("exit", (code: number) => {
|
|
119
|
+
if (code === 0 || recorded) return;
|
|
120
|
+
const markerFile = writeCrashMarker(dir, {
|
|
121
|
+
reason: "worker-exit",
|
|
122
|
+
code,
|
|
123
|
+
sessionId: options.sessionId,
|
|
124
|
+
});
|
|
125
|
+
options.onCrash?.({ kind: "exit", code, markerFile });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Internals
|
|
130
|
+
|
|
131
|
+
// Advisory only, for a human reading a directory by hand: the dying session has
|
|
132
|
+
// no terminating record, so among the sessions that look dead this names the one
|
|
133
|
+
// that wrote last. Never used as identity — see `CrashMarker.guessedSessionId`.
|
|
134
|
+
function newestOpenSessionId(dir: string): string | undefined {
|
|
135
|
+
const open = listSessions(dir).find((session) => session.crashed);
|
|
136
|
+
return open ? sessionIdFromFile(open.file) : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function classifyReason({ error, code, signal }: CrashMarkerInput): CrashReason {
|
|
140
|
+
const errorCode = (error as { code?: string } | undefined)?.code;
|
|
141
|
+
if (errorCode === "ERR_WORKER_OUT_OF_MEMORY") return "js-heap-out-of-memory";
|
|
142
|
+
if (signal === "SIGKILL") return "killed-by-os";
|
|
143
|
+
if (signal === "SIGABRT" || code === 134) return "abort-or-fatal-allocation-failure";
|
|
144
|
+
if (typeof code === "number" && code !== 0) return "nonzero-exit";
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function truncate(value: string, limit: number): string {
|
|
149
|
+
return value.length > limit ? `${value.slice(0, limit)}…` : value;
|
|
150
|
+
}
|