@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
package/src/redact.ts ADDED
@@ -0,0 +1,213 @@
1
+ import crypto from "node:crypto";
2
+ import { summarizeData, type DataSummary } from "./data_summary";
3
+
4
+ /**
5
+ * Structure-preserving redaction for anything a driver seam is handed.
6
+ *
7
+ * The definition a block model builds is recorded by *shape* rather than by a
8
+ * hand-written digest per definition type. Keys, numbers and the small set of
9
+ * strings that are schema survive verbatim; every other string is replaced by a
10
+ * hash and a length. That keeps the record useful for diagnosis, keeps customer
11
+ * data out of it by default rather than by enumeration, and works unchanged for
12
+ * definition shapes this code has never seen — the V2 query API included.
13
+ *
14
+ * The default for an unrecognised string is to hash it. A new field can
15
+ * therefore make a report less informative, but never make it leak.
16
+ */
17
+
18
+ /** Keys whose string value is schema, kept as written. */
19
+ export const SCHEMA_KEYS = new Set(["type", "name", "valueType", "kind", "operator", "mode"]);
20
+
21
+ /** Keys under which every string is schema, at any depth (axis identity). */
22
+ export const SCHEMA_SUBTREE_KEYS = new Set(["domain", "contextDomain"]);
23
+
24
+ /** Keys never descended into; summarised by counts instead. */
25
+ export const SUMMARISED_KEYS = new Set(["data", "dataInfo"]);
26
+
27
+ /** Keys reduced to a cardinality, because their contents are values. */
28
+ export const COUNTED_KEYS = new Set(["references", "parts"]);
29
+
30
+ export type RedactionStats = {
31
+ hashedStrings: number;
32
+ truncatedArrays: number;
33
+ omittedItems: number;
34
+ depthCapped: number;
35
+ opaqueObjects: number;
36
+ budgetExhausted: boolean;
37
+ };
38
+
39
+ export type RedactOptions = {
40
+ maxDepth?: number;
41
+ maxArrayItems?: number;
42
+ maxStringLength?: number;
43
+ /** Ceiling on emitted values, so one pathological definition cannot fill the log. */
44
+ maxNodes?: number;
45
+ };
46
+
47
+ export type HashedString = { h: string; n: number };
48
+
49
+ /** Redacts a definition, returning the new value and what had to be elided. */
50
+ export function redact(
51
+ value: unknown,
52
+ options: RedactOptions = {},
53
+ ): { value: unknown; stats: RedactionStats } {
54
+ const limits = {
55
+ maxDepth: options.maxDepth ?? 32,
56
+ maxArrayItems: options.maxArrayItems ?? 64,
57
+ maxStringLength: options.maxStringLength ?? 128,
58
+ maxNodes: options.maxNodes ?? 20_000,
59
+ };
60
+ const stats: RedactionStats = {
61
+ hashedStrings: 0,
62
+ truncatedArrays: 0,
63
+ omittedItems: 0,
64
+ depthCapped: 0,
65
+ opaqueObjects: 0,
66
+ budgetExhausted: false,
67
+ };
68
+ const state = { nodes: 0, seen: new WeakSet<object>() };
69
+ return {
70
+ value: walk(value, { key: undefined, schemaSubtree: false, depth: 0 }, limits, stats, state),
71
+ stats,
72
+ };
73
+ }
74
+
75
+ /** Stable short hash plus the original length. Never reversible to the value. */
76
+ export function hashString(value: string): HashedString {
77
+ return {
78
+ h: crypto.createHash("sha256").update(value).digest("hex").slice(0, 12),
79
+ n: value.length,
80
+ };
81
+ }
82
+
83
+ /** True for the object form produced in place of a redacted string. */
84
+ export function isHashedString(value: unknown): value is HashedString {
85
+ return typeof value === "object" && value !== null && "h" in value && "n" in value;
86
+ }
87
+
88
+ // Internals
89
+
90
+ type Position = { key: string | undefined; schemaSubtree: boolean; depth: number };
91
+ type Limits = Required<RedactOptions>;
92
+ type State = { nodes: number; seen: WeakSet<object> };
93
+
94
+ function walk(
95
+ value: unknown,
96
+ at: Position,
97
+ limits: Limits,
98
+ stats: RedactionStats,
99
+ state: State,
100
+ ): unknown {
101
+ if (state.nodes++ > limits.maxNodes) {
102
+ stats.budgetExhausted = true;
103
+ return { $budget: true };
104
+ }
105
+
106
+ if (value === null || value === undefined) return value ?? null;
107
+ if (typeof value === "bigint") return Number(value);
108
+ if (typeof value === "number" || typeof value === "boolean") return value;
109
+ if (typeof value === "string") return redactString(value, at, limits, stats);
110
+ if (typeof value !== "object") return { $type: typeof value };
111
+
112
+ if (at.depth >= limits.maxDepth) {
113
+ stats.depthCapped++;
114
+ return { $depth: at.depth };
115
+ }
116
+ // A definition can carry live accessors and other class instances whose
117
+ // internals reference each other; walking those is neither safe nor useful.
118
+ if (state.seen.has(value)) return { $cycle: true };
119
+
120
+ if (Array.isArray(value)) {
121
+ state.seen.add(value);
122
+ return walkArray(value, at, limits, stats, state);
123
+ }
124
+ if (!isPlainObject(value)) {
125
+ stats.opaqueObjects++;
126
+ return { $opaque: className(value) };
127
+ }
128
+
129
+ state.seen.add(value);
130
+ const out: Record<string, unknown> = {};
131
+ for (const [key, child] of Object.entries(value)) {
132
+ if (SUMMARISED_KEYS.has(key)) {
133
+ out[key] = summarizeData(child);
134
+ continue;
135
+ }
136
+ if (COUNTED_KEYS.has(key)) {
137
+ out[key] = { $count: countOf(child) };
138
+ continue;
139
+ }
140
+ out[key] = walk(
141
+ child,
142
+ {
143
+ key,
144
+ schemaSubtree: at.schemaSubtree || SCHEMA_SUBTREE_KEYS.has(key),
145
+ depth: at.depth + 1,
146
+ },
147
+ limits,
148
+ stats,
149
+ state,
150
+ );
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function walkArray(
156
+ value: unknown[],
157
+ at: Position,
158
+ limits: Limits,
159
+ stats: RedactionStats,
160
+ state: State,
161
+ ): unknown[] {
162
+ const kept = value
163
+ .slice(0, limits.maxArrayItems)
164
+ .map((item) =>
165
+ walk(
166
+ item,
167
+ { key: at.key, schemaSubtree: at.schemaSubtree, depth: at.depth + 1 },
168
+ limits,
169
+ stats,
170
+ state,
171
+ ),
172
+ );
173
+ const omitted = value.length - kept.length;
174
+ if (omitted <= 0) return kept;
175
+ // Arrays stay arrays so the rules can still walk join entries; the loss is
176
+ // recorded in the array itself rather than in a side channel.
177
+ stats.truncatedArrays++;
178
+ stats.omittedItems += omitted;
179
+ return [...kept, { $omitted: omitted }];
180
+ }
181
+
182
+ function redactString(
183
+ value: string,
184
+ at: Position,
185
+ limits: Limits,
186
+ stats: RedactionStats,
187
+ ): string | HashedString {
188
+ if (at.schemaSubtree || (at.key !== undefined && SCHEMA_KEYS.has(at.key))) {
189
+ return value.length > limits.maxStringLength
190
+ ? `${value.slice(0, limits.maxStringLength)}…`
191
+ : value;
192
+ }
193
+ stats.hashedStrings++;
194
+ return hashString(value);
195
+ }
196
+
197
+ function countOf(value: unknown): number | undefined {
198
+ if (Array.isArray(value)) return value.length;
199
+ if (isPlainObject(value)) return Object.keys(value).length;
200
+ return undefined;
201
+ }
202
+
203
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
204
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
205
+ const proto = Object.getPrototypeOf(value) as object | null;
206
+ return proto === Object.prototype || proto === null;
207
+ }
208
+
209
+ function className(value: object): string {
210
+ return (value as { constructor?: { name?: string } }).constructor?.name ?? "unknown";
211
+ }
212
+
213
+ export type { DataSummary };