@milaboratories/pl-crash-recorder 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +119 -0
  2. package/dist/data_summary.js +100 -0
  3. package/dist/data_summary.js.map +1 -0
  4. package/dist/digest.js +26 -0
  5. package/dist/digest.js.map +1 -0
  6. package/dist/events.d.ts +149 -0
  7. package/dist/events.d.ts.map +1 -0
  8. package/dist/events.js +13 -0
  9. package/dist/events.js.map +1 -0
  10. package/dist/host_sampler.d.ts +35 -0
  11. package/dist/host_sampler.d.ts.map +1 -0
  12. package/dist/host_sampler.js +54 -0
  13. package/dist/host_sampler.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.js +6 -0
  16. package/dist/instrument.d.ts +79 -0
  17. package/dist/instrument.d.ts.map +1 -0
  18. package/dist/instrument.js +305 -0
  19. package/dist/instrument.js.map +1 -0
  20. package/dist/machine_memory.js +68 -0
  21. package/dist/machine_memory.js.map +1 -0
  22. package/dist/recorder.d.ts +70 -0
  23. package/dist/recorder.d.ts.map +1 -0
  24. package/dist/recorder.js +278 -0
  25. package/dist/recorder.js.map +1 -0
  26. package/dist/redact.js +141 -0
  27. package/dist/redact.js.map +1 -0
  28. package/dist/sampler.d.ts +8 -0
  29. package/dist/sampler.d.ts.map +1 -0
  30. package/dist/sampler.js +33 -0
  31. package/dist/sampler.js.map +1 -0
  32. package/dist/sampler_thread.d.ts +1 -0
  33. package/dist/sampler_thread.js +53 -0
  34. package/dist/sampler_thread.js.map +1 -0
  35. package/dist/session.d.ts +40 -0
  36. package/dist/session.d.ts.map +1 -0
  37. package/dist/session.js +50 -0
  38. package/dist/session.js.map +1 -0
  39. package/dist/supervisor.d.ts +37 -0
  40. package/dist/supervisor.d.ts.map +1 -0
  41. package/dist/supervisor.js +138 -0
  42. package/dist/supervisor.js.map +1 -0
  43. package/package.json +43 -0
  44. package/src/data_summary.ts +163 -0
  45. package/src/digest.ts +36 -0
  46. package/src/events.ts +166 -0
  47. package/src/host_sampler.ts +83 -0
  48. package/src/index.ts +51 -0
  49. package/src/instrument.ts +480 -0
  50. package/src/machine_memory.ts +70 -0
  51. package/src/recorder.test.ts +334 -0
  52. package/src/recorder.ts +435 -0
  53. package/src/redact.test.ts +155 -0
  54. package/src/redact.ts +213 -0
  55. package/src/sampler.ts +40 -0
  56. package/src/sampler_thread.ts +60 -0
  57. package/src/session.ts +71 -0
  58. package/src/supervisor.ts +183 -0
@@ -0,0 +1,435 @@
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
+ SESSION_FILE_PREFIX,
7
+ MEM_BASELINE_RECORD,
8
+ SESSION_END_RECORD,
9
+ SESSION_RECORD,
10
+ type LogRecord,
11
+ type MemorySnapshot,
12
+ type SessionEnvironment,
13
+ } from "./events";
14
+
15
+ export type RecorderOptions = {
16
+ /** Directory holding crash 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
+ /**
38
+ * Appends a record that every later segment of the log gets again.
39
+ *
40
+ * For a fact stated once that the rest of the log is unreadable without — what
41
+ * a block id stands for, say. Written once per `key`; a rotation that discards
42
+ * the original rewrites it, so a long session cannot outlive its own legend.
43
+ *
44
+ * Past a cap on how many such facts a session may carry, the record is still
45
+ * written once and marked `notRetained`: it then survives only until the
46
+ * rotation that discards it.
47
+ */
48
+ sticky(key: string, type: string, payload?: Record<string, unknown>): void;
49
+ /** Memory reading for the calling thread; `rss` is process-wide. */
50
+ memorySnapshot(): MemorySnapshot;
51
+ /** Writes the terminating record. Its absence is how a crash is detected. */
52
+ close(reason?: string): void;
53
+ };
54
+
55
+ export type SessionFileInfo = {
56
+ file: string;
57
+ mtimeMs: number;
58
+ bytes: number;
59
+ /** True when the log has no terminating record, i.e. the process died. */
60
+ crashed: boolean;
61
+ };
62
+
63
+ type ParsedSession = {
64
+ file: string;
65
+ records: LogRecord[];
66
+ /** True when the last line was cut mid-write by the kill. */
67
+ truncatedTail: boolean;
68
+ };
69
+
70
+ /**
71
+ * Opens a crash log for this process and writes the session header.
72
+ *
73
+ * Records are appended with a synchronous write rather than through a stream:
74
+ * the process being recorded dies without warning — V8 fatal out-of-memory, a
75
+ * failed native allocation, the OS out-of-memory killer — so no exit hook, no
76
+ * flush and no `finally` block runs. Anything still sitting in a userspace
77
+ * buffer is exactly the part that would have explained the death.
78
+ */
79
+ export function openRecorder(options: RecorderOptions): Recorder {
80
+ const {
81
+ dir,
82
+ role = "middle-layer",
83
+ meta = {},
84
+ maxFileBytes = 32 * 1024 * 1024,
85
+ sessionId = newSessionId(),
86
+ } = options;
87
+ fs.mkdirSync(dir, { recursive: true });
88
+
89
+ const file = path.join(dir, `${SESSION_FILE_PREFIX}-${sessionId}.ndjson`);
90
+ const state: WriterState = {
91
+ fd: fs.openSync(file, "a"),
92
+ bytes: 0,
93
+ preambleBytes: 0,
94
+ seq: 0,
95
+ closed: false,
96
+ header: undefined,
97
+ baselineMem: undefined,
98
+ openBegins: new Map(),
99
+ sticky: new Map(),
100
+ };
101
+
102
+ const memorySnapshot = (): MemorySnapshot => {
103
+ const usage = process.memoryUsage();
104
+ return {
105
+ rss: usage.rss,
106
+ heapUsed: usage.heapUsed,
107
+ heapTotal: usage.heapTotal,
108
+ external: usage.external,
109
+ arrayBuffers: usage.arrayBuffers,
110
+ heapLimit: v8.getHeapStatistics().heap_size_limit,
111
+ };
112
+ };
113
+
114
+ const event = (type: string, payload: Record<string, unknown> = {}): number => {
115
+ if (state.closed) return -1;
116
+ const seq = ++state.seq;
117
+ const record: LogRecord = {
118
+ seq,
119
+ t: monotonic(),
120
+ wall: Date.now(),
121
+ type,
122
+ ...payload,
123
+ };
124
+ if (state.baselineMem === undefined && record.mem) state.baselineMem = record.mem;
125
+ trackOpenOperation(state, record);
126
+ writeLine(state, file, maxFileBytes, record);
127
+ return seq;
128
+ };
129
+
130
+ const sticky = (key: string, type: string, payload?: Record<string, unknown>): void => {
131
+ if (state.sticky.has(key)) return;
132
+ // The preamble is rewritten in full at every rotation, so it cannot grow
133
+ // without bound. Past the cap the fact is still written once — losing it
134
+ // entirely would leave records naming a block nothing can identify — and
135
+ // only its survival across rotation is given up. The record says so, so a
136
+ // reader can tell a missing legend from one that was never written.
137
+ const retained = state.sticky.size < MAX_STICKY_RECORDS;
138
+ const seq = event(type, retained ? payload : { ...payload, notRetained: true });
139
+ if (!retained) return;
140
+ const record = { ...payload, seq, type } as LogRecord;
141
+ state.sticky.set(key, record);
142
+ };
143
+
144
+ const recorder: Recorder = {
145
+ sessionId,
146
+ file,
147
+ event,
148
+ sticky,
149
+ memorySnapshot,
150
+ close(reason = "normal") {
151
+ if (state.closed) return;
152
+ event(SESSION_END_RECORD, { reason, mem: memorySnapshot() });
153
+ state.closed = true;
154
+ try {
155
+ fs.closeSync(state.fd);
156
+ } catch {
157
+ // Closing an already-dead descriptor must not fail shutdown.
158
+ }
159
+ },
160
+ };
161
+
162
+ // Kept so a rotated log can be given the same header again: the active file
163
+ // must describe its own session even if the parked segment is lost.
164
+ state.header = { role, pid: process.pid, meta, env: describeEnvironment() };
165
+ event(SESSION_RECORD, { ...state.header, mem: memorySnapshot() });
166
+
167
+ return recorder;
168
+ }
169
+
170
+ /**
171
+ * Periodic memory record written by the recorded thread itself.
172
+ *
173
+ * Doubles as a stall detector. This timer cannot fire while its thread is inside
174
+ * a long synchronous call, so a gap here that the independent sampler thread
175
+ * does not share means the thread was blocked — which is also why the heap
176
+ * reading nearest a synchronous blow-up is always stale.
177
+ */
178
+ export function startSelfSampler(recorder: Recorder, intervalMs = 500): () => void {
179
+ let last = Date.now();
180
+ const timer = setInterval(() => {
181
+ const now = Date.now();
182
+ recorder.event("mem-self", {
183
+ mem: recorder.memorySnapshot(),
184
+ stallMs: Math.max(0, now - last - intervalMs),
185
+ });
186
+ last = now;
187
+ }, intervalMs);
188
+ timer.unref();
189
+ return () => clearInterval(timer);
190
+ }
191
+
192
+ /** Crash logs in a directory, newest first, each flagged as crashed or clean. */
193
+ export function listSessions(dir: string): SessionFileInfo[] {
194
+ let names: string[];
195
+ try {
196
+ names = fs.readdirSync(dir);
197
+ } catch {
198
+ return [];
199
+ }
200
+ return names
201
+ .filter((name) => name.startsWith(`${SESSION_FILE_PREFIX}-`) && name.endsWith(".ndjson"))
202
+ .map((name) => {
203
+ const file = path.join(dir, name);
204
+ const stat = fs.statSync(file);
205
+ return { file, mtimeMs: stat.mtimeMs, bytes: stat.size, crashed: !hasSessionEnd(file) };
206
+ })
207
+ .sort((lhs, rhs) => rhs.mtimeMs - lhs.mtimeMs);
208
+ }
209
+
210
+ /**
211
+ * Parses a crash log, tolerating a final line cut short by a hard kill.
212
+ *
213
+ * A rotated session spans two files: the parked `.1` segment holds the original
214
+ * header and the earlier operations, the active file holds the tail. Both are
215
+ * read so begin records in one segment can be paired with end records in the
216
+ * other; sequence numbers run across the boundary.
217
+ */
218
+ export function readSession(file: string): ParsedSession {
219
+ const records: LogRecord[] = [];
220
+ let truncatedTail = false;
221
+ const parked = `${file}.1`;
222
+ if (fs.existsSync(parked)) {
223
+ for (const line of fs.readFileSync(parked, "utf8").split("\n")) {
224
+ if (line === "") continue;
225
+ try {
226
+ records.push(JSON.parse(line) as LogRecord);
227
+ } catch {
228
+ // A damaged line in the parked segment costs one record, not the session.
229
+ }
230
+ }
231
+ }
232
+ const lines = fs.readFileSync(file, "utf8").split("\n");
233
+ for (const [index, line] of lines.entries()) {
234
+ if (line === "") continue;
235
+ try {
236
+ records.push(JSON.parse(line) as LogRecord);
237
+ } catch {
238
+ if (index >= lines.length - 2) truncatedTail = true;
239
+ }
240
+ }
241
+ return { file, records, truncatedTail };
242
+ }
243
+
244
+ /**
245
+ * Mints a session id. A parent that supervises a worker calls this, hands the id
246
+ * to the worker, and keeps it for the crash marker, so both sides agree on the
247
+ * session by construction. The leading timestamp is what
248
+ * {@link sessionStartFromId} reads back.
249
+ */
250
+ export function newSessionId(): string {
251
+ return `${Date.now()}-${process.pid}-${randomTag()}`;
252
+ }
253
+
254
+ /** Wall-clock start of a session, taken from the id its file name carries. */
255
+ export function sessionStartFromId(sessionId: string): number {
256
+ const start = Number(sessionId.split("-")[0]);
257
+ return Number.isFinite(start) ? start : 0;
258
+ }
259
+
260
+ /** Session id embedded in a crash log's file name. */
261
+ export function sessionIdFromFile(file: string): string {
262
+ const match = path
263
+ .basename(file)
264
+ .match(new RegExp(`^${SESSION_FILE_PREFIX}-(.+)\\.ndjson(\\.1)?$`));
265
+ return match ? match[1] : path.basename(file);
266
+ }
267
+
268
+ // Internals
269
+
270
+ /** How many sticky records a session may keep, bounding the rewritten preamble. */
271
+ const MAX_STICKY_RECORDS = 64;
272
+
273
+ type WriterState = {
274
+ fd: number;
275
+ bytes: number;
276
+ /** Bytes of the carried-forward preamble, which do not count toward the limit. */
277
+ preambleBytes: number;
278
+ seq: number;
279
+ closed: boolean;
280
+ header: Record<string, unknown> | undefined;
281
+ /** Earliest memory reading of the session, so growth stays measurable. */
282
+ baselineMem: MemorySnapshot | undefined;
283
+ /** Begin records with no end yet, keyed by their sequence number. */
284
+ openBegins: Map<number, LogRecord>;
285
+ /** Records rewritten into every later segment, keyed by the caller's key. */
286
+ sticky: Map<string, LogRecord>;
287
+ };
288
+
289
+ /** Cap on carried-forward begins, so a leak cannot make the preamble unbounded. */
290
+ const MAX_CARRIED_BEGINS = 256;
291
+
292
+ function writeLine(
293
+ state: WriterState,
294
+ file: string,
295
+ maxFileBytes: number,
296
+ record: LogRecord,
297
+ ): void {
298
+ let line: string;
299
+ try {
300
+ line = `${JSON.stringify(record, bigintSafe)}\n`;
301
+ } catch {
302
+ line = `${JSON.stringify({ seq: record.seq, type: "record-serialization-failed" })}\n`;
303
+ }
304
+ try {
305
+ // The preamble is not charged against the limit, so a large preamble cannot
306
+ // trigger another rotation on the very next write.
307
+ if (state.bytes - state.preambleBytes + line.length > maxFileBytes) {
308
+ rotate(state, file);
309
+ writePreamble(state);
310
+ }
311
+ fs.writeSync(state.fd, line);
312
+ state.bytes += line.length;
313
+ } catch {
314
+ // A recorder that cannot write stays silent rather than cascading into the
315
+ // application it is only supposed to observe.
316
+ }
317
+ }
318
+
319
+ // The tail is the only part that explains a crash, so the old file is parked
320
+ // beside the new one instead of the new writes being dropped. Exactly one parked
321
+ // segment is kept, which bounds a session's disk use at twice the file limit.
322
+ function rotate(state: WriterState, file: string): void {
323
+ fs.closeSync(state.fd);
324
+ try {
325
+ fs.renameSync(file, `${file}.1`);
326
+ } catch {
327
+ // If the parked slot cannot be written, recording simply continues.
328
+ }
329
+ state.fd = fs.openSync(file, "a");
330
+ state.bytes = 0;
331
+ state.preambleBytes = 0;
332
+ }
333
+
334
+ /**
335
+ * Rewrites into the new segment the three things a report cannot be produced
336
+ * without, so repeated rotation costs only completed operations.
337
+ *
338
+ * Losing an *open* begin record would remove that operation from pairing
339
+ * entirely, and the operation still running at the moment of death is the one
340
+ * the report exists to name. Losing the earliest memory reading would leave the
341
+ * heap series starting mid-session while the sampler's resident series still
342
+ * starts at zero, which biases the classifier toward blaming native memory.
343
+ */
344
+ function writePreamble(state: WriterState): void {
345
+ if (state.header) {
346
+ emitPreambleRecord(state, {
347
+ seq: ++state.seq,
348
+ t: monotonic(),
349
+ wall: Date.now(),
350
+ type: SESSION_RECORD,
351
+ ...state.header,
352
+ continuation: true,
353
+ });
354
+ }
355
+ if (state.baselineMem) {
356
+ emitPreambleRecord(state, {
357
+ seq: ++state.seq,
358
+ t: monotonic(),
359
+ wall: Date.now(),
360
+ type: MEM_BASELINE_RECORD,
361
+ mem: state.baselineMem,
362
+ carriedForward: true,
363
+ });
364
+ }
365
+ for (const record of state.sticky.values()) {
366
+ emitPreambleRecord(state, { ...record, carriedForward: true });
367
+ }
368
+ // Original sequence numbers are kept, which is what lets an end record in a
369
+ // later segment pair with a begin first written in an overwritten one.
370
+ for (const begin of state.openBegins.values()) {
371
+ emitPreambleRecord(state, { ...begin, carriedForward: true });
372
+ }
373
+ }
374
+
375
+ function emitPreambleRecord(state: WriterState, record: LogRecord): void {
376
+ try {
377
+ const line = `${JSON.stringify(record, bigintSafe)}\n`;
378
+ fs.writeSync(state.fd, line);
379
+ state.bytes += line.length;
380
+ state.preambleBytes += line.length;
381
+ } catch {
382
+ // A preamble that cannot be written must not stop the session.
383
+ }
384
+ }
385
+
386
+ // Open operations are tracked by the same suffix convention the analyzer pairs
387
+ // on, so the recorder needs no separate vocabulary for them.
388
+ function trackOpenOperation(state: WriterState, record: LogRecord): void {
389
+ if (record.type.endsWith("-begin")) {
390
+ if (state.openBegins.size < MAX_CARRIED_BEGINS) state.openBegins.set(record.seq, record);
391
+ return;
392
+ }
393
+ if (record.type.endsWith("-end") || record.type.endsWith("-error")) {
394
+ if (typeof record.begin === "number") state.openBegins.delete(record.begin);
395
+ }
396
+ }
397
+
398
+ function hasSessionEnd(file: string): boolean {
399
+ const size = fs.statSync(file).size;
400
+ if (size === 0) return false;
401
+ const window = Math.min(size, 8192);
402
+ const buffer = Buffer.alloc(window);
403
+ const fd = fs.openSync(file, "r");
404
+ try {
405
+ fs.readSync(fd, buffer, 0, window, size - window);
406
+ } finally {
407
+ fs.closeSync(fd);
408
+ }
409
+ return buffer.toString("utf8").includes(`"type":"${SESSION_END_RECORD}"`);
410
+ }
411
+
412
+ function describeEnvironment(): SessionEnvironment {
413
+ const maxOldSpaceFlag = process.execArgv.find((arg) => arg.startsWith("--max-old-space-size"));
414
+ return {
415
+ node: process.version,
416
+ platform: `${process.platform}-${process.arch}`,
417
+ cpus: os.cpus().length,
418
+ totalMemory: os.totalmem(),
419
+ heapLimit: v8.getHeapStatistics().heap_size_limit,
420
+ execArgv: [...process.execArgv],
421
+ maxOldSpaceSize: maxOldSpaceFlag ? Number(maxOldSpaceFlag.split("=")[1]) : undefined,
422
+ };
423
+ }
424
+
425
+ function bigintSafe(_key: string, value: unknown): unknown {
426
+ return typeof value === "bigint" ? Number(value) : value;
427
+ }
428
+
429
+ function monotonic(): number {
430
+ return Math.round(performance.now() * 1000) / 1000;
431
+ }
432
+
433
+ function randomTag(): string {
434
+ return Math.random().toString(36).slice(2, 8);
435
+ }
@@ -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
+ });