@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.
Files changed (72) hide show
  1. package/README.md +119 -0
  2. package/dist/analyze.d.ts +133 -0
  3. package/dist/analyze.d.ts.map +1 -0
  4. package/dist/analyze.js +525 -0
  5. package/dist/analyze.js.map +1 -0
  6. package/dist/data_summary.d.ts +28 -0
  7. package/dist/data_summary.d.ts.map +1 -0
  8. package/dist/data_summary.js +73 -0
  9. package/dist/data_summary.js.map +1 -0
  10. package/dist/digest.d.ts +28 -0
  11. package/dist/digest.d.ts.map +1 -0
  12. package/dist/digest.js +55 -0
  13. package/dist/digest.js.map +1 -0
  14. package/dist/events.d.ts +89 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +12 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +13 -0
  20. package/dist/instrument.d.ts +82 -0
  21. package/dist/instrument.d.ts.map +1 -0
  22. package/dist/instrument.js +313 -0
  23. package/dist/instrument.js.map +1 -0
  24. package/dist/recorder.d.ts +82 -0
  25. package/dist/recorder.d.ts.map +1 -0
  26. package/dist/recorder.js +293 -0
  27. package/dist/recorder.js.map +1 -0
  28. package/dist/redact.d.ts +53 -0
  29. package/dist/redact.d.ts.map +1 -0
  30. package/dist/redact.js +145 -0
  31. package/dist/redact.js.map +1 -0
  32. package/dist/report.d.ts +6 -0
  33. package/dist/report.d.ts.map +1 -0
  34. package/dist/report.js +377 -0
  35. package/dist/report.js.map +1 -0
  36. package/dist/rules.d.ts +73 -0
  37. package/dist/rules.d.ts.map +1 -0
  38. package/dist/rules.js +245 -0
  39. package/dist/rules.js.map +1 -0
  40. package/dist/sampler.d.ts +22 -0
  41. package/dist/sampler.d.ts.map +1 -0
  42. package/dist/sampler.js +33 -0
  43. package/dist/sampler.js.map +1 -0
  44. package/dist/sampler_thread.d.ts +1 -0
  45. package/dist/sampler_thread.js +41 -0
  46. package/dist/sampler_thread.js.map +1 -0
  47. package/dist/session.d.ts +40 -0
  48. package/dist/session.d.ts.map +1 -0
  49. package/dist/session.js +50 -0
  50. package/dist/session.js.map +1 -0
  51. package/dist/supervisor.d.ts +58 -0
  52. package/dist/supervisor.d.ts.map +1 -0
  53. package/dist/supervisor.js +108 -0
  54. package/dist/supervisor.js.map +1 -0
  55. package/package.json +43 -0
  56. package/src/analyze.test.ts +539 -0
  57. package/src/analyze.ts +795 -0
  58. package/src/data_summary.ts +110 -0
  59. package/src/digest.ts +49 -0
  60. package/src/events.ts +102 -0
  61. package/src/index.ts +104 -0
  62. package/src/instrument.ts +442 -0
  63. package/src/recorder.ts +397 -0
  64. package/src/redact.test.ts +155 -0
  65. package/src/redact.ts +213 -0
  66. package/src/report.ts +512 -0
  67. package/src/rules.test.ts +182 -0
  68. package/src/rules.ts +383 -0
  69. package/src/sampler.ts +40 -0
  70. package/src/sampler_thread.ts +45 -0
  71. package/src/session.ts +69 -0
  72. package/src/supervisor.ts +150 -0
@@ -0,0 +1,397 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import v8 from "node:v8";
5
+ import {
6
+ FLIGHT_FILE_PREFIX,
7
+ MEM_BASELINE_RECORD,
8
+ SESSION_END_RECORD,
9
+ SESSION_RECORD,
10
+ type FlightRecord,
11
+ type MemorySnapshot,
12
+ type SessionEnvironment,
13
+ } from "./events";
14
+
15
+ export type RecorderOptions = {
16
+ /** Directory holding flight logs; created if absent. */
17
+ dir: string;
18
+ /** Which part of the app is recording, e.g. `middle-layer`. */
19
+ role?: string;
20
+ /** Free-form context stored in the session header (app version, project id). */
21
+ meta?: Record<string, unknown>;
22
+ /** Log is rotated past this size so the tail, which explains the crash, survives. */
23
+ maxFileBytes?: number;
24
+ /**
25
+ * Session id assigned by a supervising parent, so the crash marker the parent
26
+ * writes names this session with certainty rather than by inference.
27
+ * Generated when absent.
28
+ */
29
+ sessionId?: string;
30
+ };
31
+
32
+ export type Recorder = {
33
+ readonly sessionId: string;
34
+ readonly file: string;
35
+ /** Appends one record and returns its sequence number. Never throws. */
36
+ event(type: string, payload?: Record<string, unknown>): number;
37
+ /** Memory reading for the calling thread; `rss` is process-wide. */
38
+ memorySnapshot(): MemorySnapshot;
39
+ /** Writes the terminating record. Its absence is how a crash is detected. */
40
+ close(reason?: string): void;
41
+ };
42
+
43
+ export type SessionFileInfo = {
44
+ file: string;
45
+ mtimeMs: number;
46
+ bytes: number;
47
+ /** True when the log has no terminating record, i.e. the process died. */
48
+ crashed: boolean;
49
+ };
50
+
51
+ export type ParsedSession = {
52
+ file: string;
53
+ records: FlightRecord[];
54
+ /** True when the last line was cut mid-write by the kill. */
55
+ truncatedTail: boolean;
56
+ };
57
+
58
+ /**
59
+ * Opens a flight log for this process and writes the session header.
60
+ *
61
+ * Records are appended with a synchronous write rather than through a stream:
62
+ * the process being recorded dies without warning — V8 fatal out-of-memory, a
63
+ * failed native allocation, the OS out-of-memory killer — so no exit hook, no
64
+ * flush and no `finally` block runs. Anything still sitting in a userspace
65
+ * buffer is exactly the part that would have explained the death.
66
+ */
67
+ export function openRecorder(options: RecorderOptions): Recorder {
68
+ const {
69
+ dir,
70
+ role = "middle-layer",
71
+ meta = {},
72
+ maxFileBytes = 32 * 1024 * 1024,
73
+ sessionId = newSessionId(),
74
+ } = options;
75
+ fs.mkdirSync(dir, { recursive: true });
76
+
77
+ const file = path.join(dir, `${FLIGHT_FILE_PREFIX}-${sessionId}.ndjson`);
78
+ const state: WriterState = {
79
+ fd: fs.openSync(file, "a"),
80
+ bytes: 0,
81
+ preambleBytes: 0,
82
+ seq: 0,
83
+ closed: false,
84
+ header: undefined,
85
+ baselineMem: undefined,
86
+ openBegins: new Map(),
87
+ };
88
+
89
+ const memorySnapshot = (): MemorySnapshot => {
90
+ const usage = process.memoryUsage();
91
+ return {
92
+ rss: usage.rss,
93
+ heapUsed: usage.heapUsed,
94
+ heapTotal: usage.heapTotal,
95
+ external: usage.external,
96
+ arrayBuffers: usage.arrayBuffers,
97
+ heapLimit: v8.getHeapStatistics().heap_size_limit,
98
+ };
99
+ };
100
+
101
+ const event = (type: string, payload: Record<string, unknown> = {}): number => {
102
+ if (state.closed) return -1;
103
+ const seq = ++state.seq;
104
+ const record: FlightRecord = {
105
+ seq,
106
+ t: monotonic(),
107
+ wall: Date.now(),
108
+ type,
109
+ ...payload,
110
+ };
111
+ if (state.baselineMem === undefined && record.mem) state.baselineMem = record.mem;
112
+ trackOpenOperation(state, record);
113
+ writeLine(state, file, maxFileBytes, record);
114
+ return seq;
115
+ };
116
+
117
+ const recorder: Recorder = {
118
+ sessionId,
119
+ file,
120
+ event,
121
+ memorySnapshot,
122
+ close(reason = "normal") {
123
+ if (state.closed) return;
124
+ event(SESSION_END_RECORD, { reason, mem: memorySnapshot() });
125
+ state.closed = true;
126
+ try {
127
+ fs.closeSync(state.fd);
128
+ } catch {
129
+ // Closing an already-dead descriptor must not fail shutdown.
130
+ }
131
+ },
132
+ };
133
+
134
+ // Kept so a rotated log can be given the same header again: the active file
135
+ // must describe its own session even if the parked segment is lost.
136
+ state.header = { role, pid: process.pid, meta, env: describeEnvironment() };
137
+ event(SESSION_RECORD, { ...state.header, mem: memorySnapshot() });
138
+
139
+ return recorder;
140
+ }
141
+
142
+ /**
143
+ * Periodic memory record written by the recorded thread itself.
144
+ *
145
+ * Doubles as a stall detector. This timer cannot fire while its thread is inside
146
+ * a long synchronous call, so a gap here that the independent sampler thread
147
+ * does not share means the thread was blocked — which is also why the heap
148
+ * reading nearest a synchronous blow-up is always stale.
149
+ */
150
+ export function startSelfSampler(recorder: Recorder, intervalMs = 500): () => void {
151
+ let last = Date.now();
152
+ const timer = setInterval(() => {
153
+ const now = Date.now();
154
+ recorder.event("mem-self", {
155
+ mem: recorder.memorySnapshot(),
156
+ stallMs: Math.max(0, now - last - intervalMs),
157
+ });
158
+ last = now;
159
+ }, intervalMs);
160
+ timer.unref();
161
+ return () => clearInterval(timer);
162
+ }
163
+
164
+ /** Flight logs in a directory, newest first, each flagged as crashed or clean. */
165
+ export function listSessions(dir: string): SessionFileInfo[] {
166
+ let names: string[];
167
+ try {
168
+ names = fs.readdirSync(dir);
169
+ } catch {
170
+ return [];
171
+ }
172
+ return names
173
+ .filter((name) => name.startsWith(`${FLIGHT_FILE_PREFIX}-`) && name.endsWith(".ndjson"))
174
+ .map((name) => {
175
+ const file = path.join(dir, name);
176
+ const stat = fs.statSync(file);
177
+ return { file, mtimeMs: stat.mtimeMs, bytes: stat.size, crashed: !hasSessionEnd(file) };
178
+ })
179
+ .sort((lhs, rhs) => rhs.mtimeMs - lhs.mtimeMs);
180
+ }
181
+
182
+ /**
183
+ * Parses a flight log, tolerating a final line cut short by a hard kill.
184
+ *
185
+ * A rotated session spans two files: the parked `.1` segment holds the original
186
+ * header and the earlier operations, the active file holds the tail. Both are
187
+ * read so begin records in one segment can be paired with end records in the
188
+ * other; sequence numbers run across the boundary.
189
+ */
190
+ export function readSession(file: string): ParsedSession {
191
+ const records: FlightRecord[] = [];
192
+ let truncatedTail = false;
193
+ const parked = `${file}.1`;
194
+ if (fs.existsSync(parked)) {
195
+ for (const line of fs.readFileSync(parked, "utf8").split("\n")) {
196
+ if (line === "") continue;
197
+ try {
198
+ records.push(JSON.parse(line) as FlightRecord);
199
+ } catch {
200
+ // A damaged line in the parked segment costs one record, not the session.
201
+ }
202
+ }
203
+ }
204
+ const lines = fs.readFileSync(file, "utf8").split("\n");
205
+ for (const [index, line] of lines.entries()) {
206
+ if (line === "") continue;
207
+ try {
208
+ records.push(JSON.parse(line) as FlightRecord);
209
+ } catch {
210
+ if (index >= lines.length - 2) truncatedTail = true;
211
+ }
212
+ }
213
+ return { file, records, truncatedTail };
214
+ }
215
+
216
+ /**
217
+ * Mints a session id. A parent that supervises a worker calls this, hands the id
218
+ * to the worker, and keeps it for the crash marker, so both sides agree on the
219
+ * session by construction. The leading timestamp is what
220
+ * {@link sessionStartFromId} reads back.
221
+ */
222
+ export function newSessionId(): string {
223
+ return `${Date.now()}-${process.pid}-${randomTag()}`;
224
+ }
225
+
226
+ /** Wall-clock start of a session, taken from the id its file name carries. */
227
+ export function sessionStartFromId(sessionId: string): number {
228
+ const start = Number(sessionId.split("-")[0]);
229
+ return Number.isFinite(start) ? start : 0;
230
+ }
231
+
232
+ /** Session id embedded in a flight log's file name. */
233
+ export function sessionIdFromFile(file: string): string {
234
+ const match = path.basename(file).match(/^flight-(.+)\.ndjson(\.1)?$/);
235
+ return match ? match[1] : path.basename(file);
236
+ }
237
+
238
+ // Internals
239
+
240
+ type WriterState = {
241
+ fd: number;
242
+ bytes: number;
243
+ /** Bytes of the carried-forward preamble, which do not count toward the limit. */
244
+ preambleBytes: number;
245
+ seq: number;
246
+ closed: boolean;
247
+ header: Record<string, unknown> | undefined;
248
+ /** Earliest memory reading of the session, so growth stays measurable. */
249
+ baselineMem: MemorySnapshot | undefined;
250
+ /** Begin records with no end yet, keyed by their sequence number. */
251
+ openBegins: Map<number, FlightRecord>;
252
+ };
253
+
254
+ /** Cap on carried-forward begins, so a leak cannot make the preamble unbounded. */
255
+ const MAX_CARRIED_BEGINS = 256;
256
+
257
+ function writeLine(
258
+ state: WriterState,
259
+ file: string,
260
+ maxFileBytes: number,
261
+ record: FlightRecord,
262
+ ): void {
263
+ let line: string;
264
+ try {
265
+ line = `${JSON.stringify(record, bigintSafe)}\n`;
266
+ } catch {
267
+ line = `${JSON.stringify({ seq: record.seq, type: "record-serialization-failed" })}\n`;
268
+ }
269
+ try {
270
+ // The preamble is not charged against the limit, so a large preamble cannot
271
+ // trigger another rotation on the very next write.
272
+ if (state.bytes - state.preambleBytes + line.length > maxFileBytes) {
273
+ rotate(state, file);
274
+ writePreamble(state);
275
+ }
276
+ fs.writeSync(state.fd, line);
277
+ state.bytes += line.length;
278
+ } catch {
279
+ // A recorder that cannot write stays silent rather than cascading into the
280
+ // application it is only supposed to observe.
281
+ }
282
+ }
283
+
284
+ // The tail is the only part that explains a crash, so the old file is parked
285
+ // beside the new one instead of the new writes being dropped. Exactly one parked
286
+ // segment is kept, which bounds a session's disk use at twice the file limit.
287
+ function rotate(state: WriterState, file: string): void {
288
+ fs.closeSync(state.fd);
289
+ try {
290
+ fs.renameSync(file, `${file}.1`);
291
+ } catch {
292
+ // If the parked slot cannot be written, recording simply continues.
293
+ }
294
+ state.fd = fs.openSync(file, "a");
295
+ state.bytes = 0;
296
+ state.preambleBytes = 0;
297
+ }
298
+
299
+ /**
300
+ * Rewrites into the new segment the three things a report cannot be produced
301
+ * without, so repeated rotation costs only completed operations.
302
+ *
303
+ * Losing an *open* begin record would remove that operation from pairing
304
+ * entirely, and the operation still running at the moment of death is the one
305
+ * the report exists to name. Losing the earliest memory reading would leave the
306
+ * heap series starting mid-session while the sampler's resident series still
307
+ * starts at zero, which biases the classifier toward blaming native memory.
308
+ */
309
+ function writePreamble(state: WriterState): void {
310
+ if (state.header) {
311
+ emitPreambleRecord(state, {
312
+ seq: ++state.seq,
313
+ t: monotonic(),
314
+ wall: Date.now(),
315
+ type: SESSION_RECORD,
316
+ ...state.header,
317
+ continuation: true,
318
+ });
319
+ }
320
+ if (state.baselineMem) {
321
+ emitPreambleRecord(state, {
322
+ seq: ++state.seq,
323
+ t: monotonic(),
324
+ wall: Date.now(),
325
+ type: MEM_BASELINE_RECORD,
326
+ mem: state.baselineMem,
327
+ carriedForward: true,
328
+ });
329
+ }
330
+ // Original sequence numbers are kept, which is what lets an end record in a
331
+ // later segment pair with a begin first written in an overwritten one.
332
+ for (const begin of state.openBegins.values()) {
333
+ emitPreambleRecord(state, { ...begin, carriedForward: true });
334
+ }
335
+ }
336
+
337
+ function emitPreambleRecord(state: WriterState, record: FlightRecord): void {
338
+ try {
339
+ const line = `${JSON.stringify(record, bigintSafe)}\n`;
340
+ fs.writeSync(state.fd, line);
341
+ state.bytes += line.length;
342
+ state.preambleBytes += line.length;
343
+ } catch {
344
+ // A preamble that cannot be written must not stop the session.
345
+ }
346
+ }
347
+
348
+ // Open operations are tracked by the same suffix convention the analyzer pairs
349
+ // on, so the recorder needs no separate vocabulary for them.
350
+ function trackOpenOperation(state: WriterState, record: FlightRecord): void {
351
+ if (record.type.endsWith("-begin")) {
352
+ if (state.openBegins.size < MAX_CARRIED_BEGINS) state.openBegins.set(record.seq, record);
353
+ return;
354
+ }
355
+ if (record.type.endsWith("-end") || record.type.endsWith("-error")) {
356
+ if (typeof record.begin === "number") state.openBegins.delete(record.begin);
357
+ }
358
+ }
359
+
360
+ function hasSessionEnd(file: string): boolean {
361
+ const size = fs.statSync(file).size;
362
+ if (size === 0) return false;
363
+ const window = Math.min(size, 8192);
364
+ const buffer = Buffer.alloc(window);
365
+ const fd = fs.openSync(file, "r");
366
+ try {
367
+ fs.readSync(fd, buffer, 0, window, size - window);
368
+ } finally {
369
+ fs.closeSync(fd);
370
+ }
371
+ return buffer.toString("utf8").includes(`"type":"${SESSION_END_RECORD}"`);
372
+ }
373
+
374
+ function describeEnvironment(): SessionEnvironment {
375
+ const maxOldSpaceFlag = process.execArgv.find((arg) => arg.startsWith("--max-old-space-size"));
376
+ return {
377
+ node: process.version,
378
+ platform: `${process.platform}-${process.arch}`,
379
+ cpus: os.cpus().length,
380
+ totalMemory: os.totalmem(),
381
+ heapLimit: v8.getHeapStatistics().heap_size_limit,
382
+ execArgv: [...process.execArgv],
383
+ maxOldSpaceSize: maxOldSpaceFlag ? Number(maxOldSpaceFlag.split("=")[1]) : undefined,
384
+ };
385
+ }
386
+
387
+ function bigintSafe(_key: string, value: unknown): unknown {
388
+ return typeof value === "bigint" ? Number(value) : value;
389
+ }
390
+
391
+ function monotonic(): number {
392
+ return Math.round(performance.now() * 1000) / 1000;
393
+ }
394
+
395
+ function randomTag(): string {
396
+ return Math.random().toString(36).slice(2, 8);
397
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { digestDef } from "./digest";
3
+ import { isHashedString, redact } from "./redact";
4
+
5
+ const SECRET = "CASSLGQGAETQYF";
6
+
7
+ describe("redaction", () => {
8
+ test("no cell value, filter reference or annotation value survives", () => {
9
+ const digest = digestDef("PTableDef", {
10
+ src: {
11
+ type: "inner",
12
+ entries: [
13
+ {
14
+ type: "inlineColumn",
15
+ column: {
16
+ id: "inline",
17
+ spec: {
18
+ kind: "PColumn",
19
+ name: "x",
20
+ valueType: "String",
21
+ annotations: { "pl7.app/label": SECRET },
22
+ axesSpec: [{ type: "String", name: "s" }],
23
+ },
24
+ data: [{ key: [SECRET], value: SECRET }],
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ partitionFilters: [],
30
+ filters: [
31
+ {
32
+ type: "bySingleColumnV2",
33
+ column: { type: "axis", id: { type: "String", name: "s" } },
34
+ predicate: { operator: "Equal", reference: SECRET },
35
+ },
36
+ ],
37
+ sorting: [],
38
+ });
39
+ expect(JSON.stringify(digest)).not.toContain(SECRET);
40
+ });
41
+
42
+ test("schema survives verbatim so a report stays readable", () => {
43
+ const { value } = redact({
44
+ spec: {
45
+ kind: "PColumn",
46
+ name: "pl7.app/vdj/readCount",
47
+ valueType: "Long",
48
+ domain: { "pl7.app/vdj/chain": "IGH" },
49
+ axesSpec: [{ type: "String", name: "pl7.app/sampleId" }],
50
+ },
51
+ });
52
+ const spec = (value as { spec: Record<string, unknown> }).spec;
53
+ expect(spec.name).toBe("pl7.app/vdj/readCount");
54
+ expect(spec.valueType).toBe("Long");
55
+ // Domain values carry join identity, so they are kept at any depth.
56
+ expect(spec.domain).toEqual({ "pl7.app/vdj/chain": "IGH" });
57
+ });
58
+
59
+ test("annotation keys are kept and their values hashed", () => {
60
+ const { value } = redact({ annotations: { "pl7.app/label": SECRET } });
61
+ const annotations = (value as { annotations: Record<string, unknown> }).annotations;
62
+ expect(Object.keys(annotations)).toEqual(["pl7.app/label"]);
63
+ expect(isHashedString(annotations["pl7.app/label"])).toBe(true);
64
+ expect(annotations["pl7.app/label"]).toMatchObject({ n: SECRET.length });
65
+ });
66
+
67
+ test("the same value hashes the same way, so two labels can be compared", () => {
68
+ const a = redact({ note: SECRET }).value as { note: { h: string } };
69
+ const b = redact({ note: SECRET }).value as { note: { h: string } };
70
+ const c = redact({ note: `${SECRET}x` }).value as { note: { h: string } };
71
+ expect(a.note.h).toBe(b.note.h);
72
+ expect(a.note.h).not.toBe(c.note.h);
73
+ });
74
+
75
+ test("a class instance is named, not walked", () => {
76
+ class TreeAccessor {
77
+ constructor(public readonly secret = SECRET) {}
78
+ }
79
+ const { value, stats } = redact({ data: { type: "x" }, accessor: new TreeAccessor() });
80
+ expect(JSON.stringify(value)).not.toContain(SECRET);
81
+ expect((value as { accessor: unknown }).accessor).toEqual({ $opaque: "TreeAccessor" });
82
+ expect(stats.opaqueObjects).toBe(1);
83
+ });
84
+
85
+ test("a cycle is marked instead of hanging", () => {
86
+ const node: Record<string, unknown> = { type: "column" };
87
+ node.self = node;
88
+ const { value } = redact(node);
89
+ expect((value as { self: unknown }).self).toEqual({ $cycle: true });
90
+ });
91
+
92
+ test("a long array keeps its head, stays an array, and records the loss", () => {
93
+ const { value, stats } = redact(
94
+ { entries: Array.from({ length: 200 }, (_, i) => ({ i })) },
95
+ {
96
+ maxArrayItems: 8,
97
+ },
98
+ );
99
+ const entries = (value as { entries: unknown[] }).entries;
100
+ expect(Array.isArray(entries)).toBe(true);
101
+ expect(entries).toHaveLength(9);
102
+ expect(entries.at(-1)).toEqual({ $omitted: 192 });
103
+ expect(stats.omittedItems).toBe(192);
104
+ });
105
+
106
+ test("a column payload is replaced by counts and never descended into", () => {
107
+ const digest = digestDef("PTableDef", {
108
+ src: {
109
+ type: "column",
110
+ column: {
111
+ id: "c",
112
+ spec: { kind: "PColumn", name: "c", valueType: "Int", axesSpec: [] },
113
+ data: {
114
+ type: "ParquetPartitioned",
115
+ partitionKeyLength: 1,
116
+ parts: {
117
+ [`["${SECRET}"]`]: { data: SECRET, stats: { numberOfRows: 7, size: { column: 3 } } },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ partitionFilters: [],
123
+ filters: [],
124
+ sorting: [],
125
+ });
126
+ expect(JSON.stringify(digest)).not.toContain(SECRET);
127
+ const data = (digest.def as { src: { column: { data: Record<string, unknown> } } }).src.column
128
+ .data;
129
+ expect(data).toMatchObject({ kind: "ParquetPartitioned", parts: 1, rows: 7 });
130
+ });
131
+
132
+ test("an inline payload is reduced to a count and a sampled size", () => {
133
+ const values = Array.from({ length: 1000 }, (_, i) => ({ key: [`k${i}`], value: SECRET }));
134
+ const { value } = redact({ data: values });
135
+ const data = (value as { data: Record<string, unknown> }).data;
136
+ expect(JSON.stringify(data)).not.toContain(SECRET);
137
+ expect(data.kind).toBe("inline");
138
+ expect(data.entries).toBe(1000);
139
+ expect(data.approxBytes as number).toBeGreaterThan(0);
140
+ });
141
+
142
+ test("a deep definition is cut rather than followed forever", () => {
143
+ let node: Record<string, unknown> = { type: "leaf" };
144
+ for (let i = 0; i < 50; i++) node = { type: "wrap", input: node };
145
+ const { value, stats } = redact(node, { maxDepth: 6 });
146
+ expect(stats.depthCapped).toBeGreaterThan(0);
147
+ expect(JSON.stringify(value)).toContain("$depth");
148
+ });
149
+
150
+ test("the node budget bounds one record", () => {
151
+ const wide = { entries: Array.from({ length: 500 }, (_, i) => ({ type: "column", i })) };
152
+ const { stats } = redact(wide, { maxNodes: 50, maxArrayItems: 500 });
153
+ expect(stats.budgetExhausted).toBe(true);
154
+ });
155
+ });