@haystackeditor/cli 0.24.1 → 0.25.1

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 (51) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. package/schemas/pre-verify.v1.json +60 -3
@@ -1,379 +1,152 @@
1
1
  'use strict';
2
2
 
3
3
  /*
4
- * Haystack's automatically injected Node telemetry runtime.
4
+ * Haystack's automatically injected Node telemetry runtime: sampler version 5
5
+ * (CAPTURE-V1 rule 9, infra/lambda/haystack-design-verifier-shared/capture_contracts.ts).
5
6
  *
6
- * This file is copied beside compiled application code by
7
- * `haystack telemetry instrument`. It has no package dependencies, records no
8
- * raw values, and is inert unless all three HAYSTACK_TELEMETRY_* activation
9
- * variables are present. Every application-facing hook returns the original
10
- * value and catches its own errors so telemetry cannot change app behavior.
7
+ * This file is copied beside compiled application code by `haystack telemetry
8
+ * instrument` (or the Next.js wrapper). It has no package dependencies, never
9
+ * records a value (only its shape and size, or an approved setting literal),
10
+ * and is inert unless all three HAYSTACK_TELEMETRY_* variables are present.
11
+ *
12
+ * Rule 9(a): every call of a site is counted (one increment); the first
13
+ * SERVER_SAMPLE_AFTER_CALLS calls of a site in this process are classified
14
+ * (the census), later ones with probability 1/SERVER_SAMPLE_RATE from a per-site
15
+ * PRNG; a per-second budget bounds classification and counts what it skipped.
16
+ * A flush sends per site its window's calls, census, sampled and skipped deltas.
17
+ *
18
+ * Rule 13(b), do no harm:
19
+ * - every probe returns its argument unchanged and no exception leaves this
20
+ * file; classification never runs customer code (no getters, proxy traps,
21
+ * valueOf or Symbol.toPrimitive), and nothing it sends is serialized by
22
+ * JSON.stringify of an object (which would look up an inherited toJSON):
23
+ * bodies are assembled from primitives only;
24
+ * - every byte it keeps (site metadata, field keys, counters, a window being
25
+ * serialized, bodies waiting to be sent) is accounted against
26
+ * SERVER_RUNTIME_MAX_BYTES: new categories past the counters' share become
27
+ * `overflow`, and anything that would cross the cap turns telemetry off;
28
+ * - flushes run on an unref'd timer, one send in flight, each bounded by
29
+ * SERVER_SEND_TIMEOUT_MS; three failed sends stop sending and counting;
30
+ * - the process exits as it would without telemetry: the beforeExit flush has
31
+ * one absolute SERVER_EXIT_FLUSH_MS deadline across everything it does, and
32
+ * the exit and crash paths make at most one bounded synchronous write;
33
+ * - no process handlers but the passive uncaughtExceptionMonitor, and no
34
+ * output at all (a stalled stderr pipe could block the event loop); why it
35
+ * turned itself off is in status().
11
36
  */
12
37
 
13
38
  const { createHash, randomBytes } = require('node:crypto');
14
- const { AsyncLocalStorage } = require('node:async_hooks');
15
- const diagnosticsChannel = require('node:diagnostics_channel');
16
39
  const { types: utilTypes } = require('node:util');
17
- const { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } = require('node:fs');
40
+ const { readFileSync, writeFileSync, promises: fsPromises } = require('node:fs');
18
41
  const { tmpdir } = require('node:os');
19
42
  const { join } = require('node:path');
20
43
 
21
44
  // Capture the few mutable language intrinsics used on customer values before
22
45
  // application code runs. Calling a customer-patched prototype method from a
23
46
  // probe would itself be an application-visible side effect.
24
- const safeStringTrim = Function.call.bind(String.prototype.trim);
25
47
  const safeString = String;
26
- const safeStringStartsWith = Function.call.bind(String.prototype.startsWith);
27
- const safeStringEndsWith = Function.call.bind(String.prototype.endsWith);
28
- const safeStringIndexOf = Function.call.bind(String.prototype.indexOf);
29
48
  const safeStringSlice = Function.call.bind(String.prototype.slice);
30
49
  const safeStringCharCodeAt = Function.call.bind(String.prototype.charCodeAt);
50
+ const safeStringStartsWith = Function.call.bind(String.prototype.startsWith);
31
51
  const safeRegExpTest = Function.call.bind(RegExp.prototype.test);
32
52
  const safeJsonParse = JSON.parse;
53
+ // Only ever called on primitives (strings, finite numbers): JSON.stringify
54
+ // looks up toJSON on objects, never on primitives.
33
55
  const safeJsonStringify = JSON.stringify;
34
56
  const safeArrayIsArray = Array.isArray;
35
- const safeArrayFilter = Function.call.bind(Array.prototype.filter);
36
- const safeArrayFind = Function.call.bind(Array.prototype.find);
37
- const safeArrayFlatMap = Function.call.bind(Array.prototype.flatMap);
38
- const safeArrayMap = Function.call.bind(Array.prototype.map);
39
- const safeArrayReduce = Function.call.bind(Array.prototype.reduce);
40
- const safeArrayShift = Function.call.bind(Array.prototype.shift);
41
- const safeArraySlice = Function.call.bind(Array.prototype.slice);
57
+ const safeArrayPush = Function.call.bind(Array.prototype.push);
58
+ const safeArrayJoin = Function.call.bind(Array.prototype.join);
42
59
  const safeArraySort = Function.call.bind(Array.prototype.sort);
43
- const safeNumber = Number;
44
- const safeNumberIsFinite = Number.isFinite;
60
+ const safeArrayShift = Function.call.bind(Array.prototype.shift);
45
61
  const safeNumberIsSafeInteger = Number.isSafeInteger;
46
62
  const safeNumberParseInt = Number.parseInt;
47
63
  const safeMathImul = Math.imul;
48
- const safeMathMax = Math.max;
49
64
  const safeMathMin = Math.min;
50
65
  const safeBufferByteLength = Buffer.byteLength;
51
- const safeObjectKeys = Object.keys;
52
- const safeObjectEntries = Object.entries;
53
66
  const safeObjectCreate = Object.create;
54
67
  const safeObjectFreeze = Object.freeze;
55
68
  const safeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
56
69
  const safeGetPrototypeOf = Object.getPrototypeOf;
57
70
  const safeHasOwn = Function.call.bind(Object.prototype.hasOwnProperty);
71
+ const ObjectPrototype = Object.prototype;
72
+ const SafeMap = Map;
58
73
  const safeMapGet = Function.call.bind(Map.prototype.get);
59
74
  const safeMapSet = Function.call.bind(Map.prototype.set);
60
- const safeMapHas = Function.call.bind(Map.prototype.has);
61
- const safeMapSize = Function.call.bind(Object.getOwnPropertyDescriptor(Map.prototype, 'size').get);
62
75
  const safeMapEntries = Function.call.bind(Map.prototype.entries);
63
- const safeMapValues = Function.call.bind(Map.prototype.values);
64
- const safeWeakMapGet = Function.call.bind(WeakMap.prototype.get);
65
- const safeWeakMapSet = Function.call.bind(WeakMap.prototype.set);
66
- const safeSetAdd = Function.call.bind(Set.prototype.add);
67
- const safeSetValues = Function.call.bind(Set.prototype.values);
68
- const safeArrayPush = Function.call.bind(Array.prototype.push);
69
- const SafeMap = Map;
70
- const SafeSet = Set;
71
- const SafeWeakMap = WeakMap;
76
+ const safeMapSize = Function.call.bind(Object.getOwnPropertyDescriptor(Map.prototype, 'size').get);
77
+ const safeSetSize = Function.call.bind(Object.getOwnPropertyDescriptor(Set.prototype, 'size').get);
78
+ const safeMapIteratorNext = Function.call.bind(Object.getPrototypeOf(safeMapEntries(new SafeMap())).next);
72
79
  const SafeDate = Date;
73
80
  const safeDateNow = Date.now;
74
81
  const safeDateToISOString = Function.call.bind(Date.prototype.toISOString);
75
- const safeMapIteratorNext = Function.call.bind(
76
- Object.getPrototypeOf(safeMapEntries(new SafeMap())).next,
77
- );
78
- const safeSetIteratorNext = Function.call.bind(
79
- Object.getPrototypeOf(safeSetValues(new SafeSet())).next,
80
- );
81
82
  const hashPrototype = Object.getPrototypeOf(createHash('sha256'));
82
83
  const safeHashUpdate = Function.call.bind(hashPrototype.update);
83
84
  const safeHashDigest = Function.call.bind(hashPrototype.digest);
84
85
  const safeIsProxy = utilTypes.isProxy;
86
+ const safeIsMap = utilTypes.isMap;
87
+ const safeIsSet = utilTypes.isSet;
85
88
  const safeIsNativeError = utilTypes.isNativeError;
86
- const safeActionGetStore = Function.call.bind(AsyncLocalStorage.prototype.getStore);
87
- const safeActionEnterWith = Function.call.bind(AsyncLocalStorage.prototype.enterWith);
89
+ const SafePromise = Promise;
88
90
  const safePromiseFinally = Function.call.bind(Promise.prototype.finally);
89
- const safeFetch = typeof fetch === 'function' ? fetch : null;
90
- const safeAbortTimeout = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout : null;
91
- const safeResponseOk = typeof Response === 'function'
92
- ? Function.call.bind(Object.getOwnPropertyDescriptor(Response.prototype, 'ok').get)
93
- : null;
94
- const safeResponseStatus = typeof Response === 'function'
95
- ? Function.call.bind(Object.getOwnPropertyDescriptor(Response.prototype, 'status').get)
96
- : null;
91
+ const safePromiseThen = Function.call.bind(Promise.prototype.then);
92
+ // Sends are one HTTP/1.1 request written on a socket of their own: the socket
93
+ // is unref'd, so a send in flight never keeps the process alive (fetch's would,
94
+ // up to its timeout, past the application's own end), and no HTTP client code
95
+ // runs (node:http checks parser results with `instanceof Error`, which would
96
+ // call a customer's Error[Symbol.hasInstance]).
97
+ const { connect: safeNetConnect, isIP: safeIsIP } = require('node:net');
98
+ const { connect: safeTlsConnect } = require('node:tls');
99
+ const safeBufferToString = Function.call.bind(Buffer.prototype.toString);
100
+ const safeStringIndexOf = Function.call.bind(String.prototype.indexOf);
101
+ const safeSetTimeout = setTimeout;
102
+ const safeClearTimeout = clearTimeout;
103
+ const safeSetInterval = setInterval;
97
104
  const safeClearInterval = clearInterval;
98
- const SafeError = Error;
99
- const nativeErrorPrototypes = safeObjectFreeze({
100
- type: TypeError.prototype,
101
- range: RangeError.prototype,
102
- syntax: SyntaxError.prototype,
103
- reference: ReferenceError.prototype,
104
- aggregate: typeof AggregateError === 'function' ? AggregateError.prototype : null,
105
- uri: URIError.prototype,
106
- eval: EvalError.prototype,
107
- generic: Error.prototype,
108
- });
109
-
110
- function compareStrings(left, right) {
111
- return left < right ? -1 : left > right ? 1 : 0;
112
- }
113
-
114
- function mapEntriesArray(map) {
115
- const result = [];
116
- const iterator = safeMapEntries(map);
117
- while (true) {
118
- const step = safeMapIteratorNext(iterator);
119
- if (step.done) return result;
120
- safeArrayPush(result, step.value);
121
- }
122
- }
123
-
124
- function mapValuesArray(map) {
125
- const entries = mapEntriesArray(map);
126
- const result = [];
127
- for (let index = 0; index < entries.length; index++) safeArrayPush(result, entries[index][1]);
128
- return result;
129
- }
130
-
131
- function setValuesArray(set) {
132
- const result = [];
133
- const iterator = safeSetValues(set);
134
- while (true) {
135
- const step = safeSetIteratorNext(iterator);
136
- if (step.done) return result;
137
- safeArrayPush(result, step.value);
138
- }
139
- }
140
-
141
- function objectFromEntryArray(entries) {
142
- const result = safeObjectCreate(null);
143
- for (let index = 0; index < entries.length; index++) {
144
- const entry = entries[index];
145
- result[entry[0]] = entry[1];
146
- }
147
- return result;
148
- }
149
-
150
- function objectFromMap(map) {
151
- return objectFromEntryArray(mapEntriesArray(map));
152
- }
153
-
154
- function sha256Hex(value) {
155
- const hash = createHash('sha256');
156
- safeHashUpdate(hash, value);
157
- return safeHashDigest(hash, 'hex');
158
- }
159
-
160
- function responseAccepted(response) {
161
- try {
162
- return safeResponseOk ? safeResponseOk(response) : false;
163
- } catch {
164
- return false;
165
- }
166
- }
105
+ const safeSetImmediate = setImmediate;
106
+ const noop = () => {};
107
+ const nextTurn = () => new SafePromise(resolve => safeSetImmediate(resolve));
108
+ const processHost = require('node:process');
167
109
 
168
- function responseStatus(response) {
169
- try {
170
- return safeResponseStatus ? safeResponseStatus(response) : 0;
171
- } catch {
172
- return 0;
173
- }
174
- }
110
+ // The contract's sampler constants and category vocabulary, substituted from
111
+ // the CLI's mirror of capture_contracts.ts when the runtime is authenticated.
112
+ const CONTRACT = /* __HAYSTACK_SERVER_CONTRACT__ */ null;
175
113
 
176
- const RUNTIME_VERSION = 1;
177
- const SAMPLER_VERSION = 4;
114
+ const RUNTIME_VERSION = 2;
178
115
  const DEFAULT_FLUSH_INTERVAL_MS = 60_000;
179
- const DEFAULT_SITE_OBSERVATIONS = 8;
180
- const MAX_SITE_OBSERVATIONS = 4_096;
181
- const DEFAULT_PROCESS_PROBE_OBSERVATIONS = 4_096;
182
- const MAX_PROCESS_PROBE_OBSERVATIONS = 65_536;
183
- const MAX_SAFE_COUNT = Number.MAX_SAFE_INTEGER;
184
- const MAX_COUNTER_KEYS = 50_000;
185
- const MAX_EXECUTION_FAMILIES = 64;
186
- const MAX_EXECUTION_EVENTS = 48;
187
- const MAX_EXECUTION_ACTION_BYTES = 24 * 1024;
188
- const MAX_EXECUTION_BYTES = 1024 * 1024;
189
116
  const MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
190
117
  const PAYLOAD_CHUNK_BUDGET = MAX_PAYLOAD_BYTES - 256 * 1024;
118
+ // Crash and exit spool: at most MAX_SPOOL_FILES fixed slots of at most
119
+ // SPOOL_FILE_BYTES each, so a slot needs no directory listing to stay bounded.
191
120
  const MAX_SPOOL_FILES = 8;
192
- const MAX_SPOOL_BYTES = 16 * 1024 * 1024;
193
- const SAMPLE_FIRST_ACTIONS = 4;
194
- const DEFAULT_PROCESS_SAMPLE_RATE = 1;
195
- const DEFAULT_ACTION_SAMPLE_RATE = 64;
196
- const MAX_ACTION_SAMPLE_RATE = 4_096;
121
+ const SPOOL_FILE_BYTES = 2 * 1024 * 1024;
197
122
  const MAX_CONSECUTIVE_FAILURES = 3;
123
+ // Sites serialized per event-loop turn by an interval flush.
124
+ const FLUSH_SLICE_SITES = 500;
125
+ // Memory accounting (bytes): what a site's state, metadata and lookup entry
126
+ // keep beyond their strings; a string's header; a Map's and a Map entry's
127
+ // footprint. Estimates on the high side, so the cap is a true upper bound.
128
+ const SITE_BYTES = 480;
129
+ const STRING_BYTES = 24;
130
+ const MAP_BYTES = 160;
131
+ const ENTRY_BYTES = 96;
132
+ // Of the cap: what a window's counters may use before new categories become
133
+ // overflow (the rest serializes them into bodies), and what is kept back for
134
+ // overflow entries themselves.
135
+ const COUNTER_SHARE = 0.4;
136
+ const RESERVE_BYTES = 1024 * 1024;
198
137
  const CONTROL_GLOBAL = '__haystackTelemetryControlV1';
199
138
  const CONTROL_CHANNEL = '__HAYSTACK_TELEMETRY_CONTROL_CHANNEL__';
200
139
  const RUNTIME_INTEGRITY = '__HAYSTACK_TELEMETRY_RUNTIME_INTEGRITY__';
201
- const SUPPRESSED_ACTION = safeObjectFreeze({ suppressed: true });
140
+ const MANIFEST_SCHEMA_VERSION = 'haystack-node-telemetry-instrumentation-v4';
202
141
 
203
142
  let installed = false;
143
+ let installResult = null;
204
144
  let activeControl = null;
205
- const processHost = require('node:process');
206
-
207
- function selectProbe(name, fallback) {
208
- try {
209
- const selected = activeControl?.selectProbe(name);
210
- return typeof selected === 'function' ? selected : fallback;
211
- } catch {
212
- return fallback;
213
- }
214
- }
215
145
 
216
- function trustedSelectProbe(name, fallback) {
217
- return selectProbe(name, fallback);
218
- }
219
-
220
- Object.defineProperty(trustedSelectProbe, 'haystackRuntimeIntegrity', {
221
- configurable: false,
222
- enumerable: false,
223
- writable: false,
224
- value: RUNTIME_INTEGRITY,
225
- });
226
-
227
- function safeSelectProbe(expectedIntegrity, name, fallback) {
228
- try {
229
- if (expectedIntegrity !== RUNTIME_INTEGRITY) return fallback;
230
- return trustedSelectProbe(name, fallback);
231
- } catch {
232
- return fallback;
233
- }
234
- }
235
-
236
- function safeSelectSiteProbe(expectedIntegrity, name, site, fallback) {
237
- try {
238
- if (expectedIntegrity !== RUNTIME_INTEGRITY) return fallback;
239
- const selected = activeControl?.selectSiteProbe(name, site);
240
- return typeof selected === 'function' ? selected : fallback;
241
- } catch {
242
- return fallback;
243
- }
244
- }
245
-
246
- function debug(message, error) {
247
- if (process.env.HAYSTACK_TELEMETRY_DEBUG !== '1') return;
248
- const detail = safeIsNativeError(error) ? ': native error' : error ? ': telemetry error' : '';
249
- process.stderr.write(`[haystack-telemetry] ${message}${detail}\n`);
250
- }
251
-
252
- function safeSegment(value, fallback) {
253
- return typeof value === 'string' && safeRegExpTest(/^[A-Za-z0-9._-]{1,128}$/, value) ? value : fallback;
254
- }
255
-
256
- function sizeBucket(length) {
257
- if (length === 0) return 'empty';
258
- if (length === 1) return 'one';
259
- if (length <= 4) return 'few';
260
- return 'many';
261
- }
262
-
263
- function stringForm(value) {
264
- if (value.length === 0) return 'empty';
265
- const trimmed = safeStringTrim(value);
266
- if (!trimmed) return 'whitespace';
267
- // Bound parsing work. Long strings still report their generic form/size;
268
- // their contents are never retained or transmitted.
269
- if (trimmed.length <= 4_096 && safeStringStartsWith(trimmed, '```') && safeStringEndsWith(trimmed, '```')) {
270
- const newline = safeStringIndexOf(trimmed, '\n');
271
- if (newline >= 0) {
272
- const body = safeStringTrim(safeStringSlice(trimmed, newline + 1, -3));
273
- try {
274
- const parsed = safeJsonParse(body);
275
- return safeArrayIsArray(parsed)
276
- ? 'markdown-fenced-json-array'
277
- : parsed !== null && typeof parsed === 'object'
278
- ? 'markdown-fenced-json-object'
279
- : 'markdown-fenced-json-scalar';
280
- } catch {
281
- return 'markdown-fenced-text';
282
- }
283
- }
284
- return 'markdown-fenced-text';
285
- }
286
- if (trimmed.length <= 4_096) {
287
- try {
288
- const parsed = safeJsonParse(trimmed);
289
- return safeArrayIsArray(parsed)
290
- ? 'json-array'
291
- : parsed !== null && typeof parsed === 'object'
292
- ? 'json-object'
293
- : 'json-scalar';
294
- } catch {
295
- // Ordinary text is the expected case.
296
- }
297
- }
298
- return 'text';
299
- }
300
-
301
- function valueShape(value) {
302
- if (value === null) return 'null';
303
- if (value === undefined) return 'undefined';
304
- if (typeof value === 'string') return `string:${stringForm(value)}:${sizeBucket(value.length)}`;
305
- if (typeof value === 'number') return safeNumberIsFinite(value) ? 'number:finite' : 'number:nonfinite';
306
- if (typeof value === 'boolean') return 'boolean';
307
- if (typeof value === 'object' && safeIsProxy(value)) return 'object:uninspectable';
308
- if (safeArrayIsArray(value)) return `array:${sizeBucket(value.length)}`;
309
- if (typeof value === 'object') {
310
- try {
311
- if (safeIsNativeError(value)) return `error:${errorClass(value)}`;
312
- return `object:${sizeBucket(safeObjectKeys(value).length)}`;
313
- } catch {
314
- return 'object:uninspectable';
315
- }
316
- }
317
- return `nonjson:${typeof value}`;
318
- }
319
-
320
- function stableIdentity(prefix, parts) {
321
- let framed = '';
322
- for (let index = 0; index < parts.length; index++) {
323
- if (index > 0) framed += '|';
324
- const part = parts[index];
325
- framed += `${safeBufferByteLength(part, 'utf8')}:${part}`;
326
- }
327
- return `${prefix}-${safeStringSlice(sha256Hex(framed), 0, 24)}`;
328
- }
329
-
330
- function canonicalJson(value) {
331
- if (value === null || typeof value !== 'object') return safeJsonStringify(value);
332
- if (safeArrayIsArray(value)) {
333
- let result = '[';
334
- for (let index = 0; index < value.length; index++) {
335
- if (index > 0) result += ',';
336
- result += canonicalJson(value[index]);
337
- }
338
- return `${result}]`;
339
- }
340
- const keys = safeArraySort(safeObjectKeys(value), compareStrings);
341
- let result = '{';
342
- for (let index = 0; index < keys.length; index++) {
343
- if (index > 0) result += ',';
344
- const key = keys[index];
345
- result += `${safeJsonStringify(key)}:${canonicalJson(value[key])}`;
346
- }
347
- return `${result}}`;
348
- }
349
-
350
- function errorClass(value) {
351
- try {
352
- if (value !== null && typeof value === 'object' && safeIsProxy(value)) return 'uninspectable';
353
- if (safeIsNativeError(value)) {
354
- // Compare the direct native prototype with references captured before
355
- // application code. This retains common built-in classes without
356
- // invoking mutable Symbol.hasInstance/name/tag hooks.
357
- const prototype = safeGetPrototypeOf(value);
358
- if (prototype === nativeErrorPrototypes.type) return 'type';
359
- if (prototype === nativeErrorPrototypes.range) return 'range';
360
- if (prototype === nativeErrorPrototypes.syntax) return 'syntax';
361
- if (prototype === nativeErrorPrototypes.reference) return 'reference';
362
- if (prototype === nativeErrorPrototypes.aggregate) return 'aggregate';
363
- if (prototype === nativeErrorPrototypes.uri) return 'uri';
364
- if (prototype === nativeErrorPrototypes.eval) return 'eval';
365
- return 'generic';
366
- }
367
- } catch {
368
- return 'uninspectable';
369
- }
370
- if (value === null) return 'non-error-null';
371
- return `non-error-${typeof value}`;
372
- }
373
-
374
- function httpMethod(value) {
375
- return value === 'GET' || value === 'POST' || value === 'PUT' || value === 'PATCH' ||
376
- value === 'DELETE' || value === 'HEAD' || value === 'OPTIONS' ? value : 'OTHER';
146
+ function sha256Hex(value) {
147
+ const hash = createHash('sha256');
148
+ safeHashUpdate(hash, value);
149
+ return safeHashDigest(hash, 'hex');
377
150
  }
378
151
 
379
152
  function hashSeed(value) {
@@ -382,15 +155,24 @@ function hashSeed(value) {
382
155
  hash ^= safeStringCharCodeAt(value, index);
383
156
  hash = safeMathImul(hash, 0x01000193);
384
157
  }
158
+ // xorshift must never be seeded with 0.
385
159
  return (hash >>> 0) || 1;
386
160
  }
387
161
 
162
+ function stringBytes(value) {
163
+ return STRING_BYTES + value.length * 2;
164
+ }
165
+
166
+ function safeSegment(value, fallback) {
167
+ return typeof value === 'string' && safeRegExpTest(/^[A-Za-z0-9._-]{1,128}$/, value) ? value : fallback;
168
+ }
169
+
388
170
  function validEndpoint(raw) {
389
171
  try {
390
172
  const endpoint = new URL(raw);
391
173
  if (endpoint.protocol === 'https:') return endpoint.toString();
392
174
  if (endpoint.protocol === 'http:' && (endpoint.hostname === '127.0.0.1' ||
393
- endpoint.hostname === 'localhost' || endpoint.hostname === '::1')) {
175
+ endpoint.hostname === 'localhost' || endpoint.hostname === '[::1]')) {
394
176
  return endpoint.toString();
395
177
  }
396
178
  } catch {
@@ -399,82 +181,137 @@ function validEndpoint(raw) {
399
181
  return null;
400
182
  }
401
183
 
184
+ /** A JSON string literal of a primitive string. */
185
+ function quote(value) {
186
+ return safeJsonStringify(value);
187
+ }
188
+
189
+ /** Rule 9(c): an approved literal is a boolean, a safe integer or a short
190
+ * printable string, and its category is `setting:` plus exactly this JSON. */
191
+ function literalJson(literal) {
192
+ if (literal === true) return 'true';
193
+ if (literal === false) return 'false';
194
+ if (typeof literal === 'number') return `${literal}`;
195
+ return quote(literal);
196
+ }
197
+
198
+ function validSettingLiteral(literal) {
199
+ if (typeof literal === 'boolean') return true;
200
+ if (typeof literal === 'number') return safeNumberIsSafeInteger(literal);
201
+ if (typeof literal !== 'string' || literal.length < 1 || literal.length > CONTRACT.SETTINGS_MAX_LITERAL_CHARS) return false;
202
+ for (let index = 0; index < literal.length; index++) {
203
+ const code = safeStringCharCodeAt(literal, index);
204
+ if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) return false;
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /** An own data property of a parsed manifest object (never an inherited one). */
210
+ function own(object, key) {
211
+ if (object === null || typeof object !== 'object' || !safeHasOwn(object, key)) return undefined;
212
+ const descriptor = safeGetOwnPropertyDescriptor(object, key);
213
+ return descriptor && safeHasOwn(descriptor, 'value') ? descriptor.value : undefined;
214
+ }
215
+
402
216
  function loadInstrumentation() {
403
- try {
404
- const manifest = safeJsonParse(readFileSync(join(__dirname, 'instrumentation.json'), 'utf8'));
405
- if (manifest.schema_version !== 'haystack-node-telemetry-instrumentation-v3' ||
406
- !safeArrayIsArray(manifest.sites)) return { sites: new SafeMap(), entrySourcePath: null };
407
- const siteEntries = safeArrayMap(manifest.sites, site => [site.probe_key, safeObjectFreeze({
408
- probeKey: site.probe_key,
409
- siteId: site.site_id,
410
- sourcePath: site.source_path,
411
- qualifiedName: site.qualified_name,
412
- probeKind: site.probe_kind,
413
- ownerKey: site.owner_key,
414
- structuralKey: site.structural_key,
415
- ...(site.label ? { label: site.label } : {}),
416
- allowedFields: safeArrayIsArray(site.allowed_fields)
417
- ? safeArraySlice(site.allowed_fields, 0)
418
- : [],
419
- // Source position, present only when the build instrumented source
420
- // itself (bundler loader). Joins to the change's function line ranges.
421
- ...(safeNumberIsSafeInteger(site.line) && site.line >= 1
422
- ? {
423
- line: site.line,
424
- ...(safeNumberIsSafeInteger(site.column) && site.column >= 0 ? { column: site.column } : {}),
425
- }
426
- : {}),
427
- })]);
428
- const sites = new SafeMap();
429
- for (let index = 0; index < siteEntries.length; index++) {
430
- safeMapSet(sites, siteEntries[index][0], siteEntries[index][1]);
217
+ const FIELD_NAME = new RegExp(CONTRACT.SERVER_FIELD_NAME_PATTERN);
218
+ const manifest = safeJsonParse(readFileSync(join(__dirname, 'instrumentation.json'), 'utf8'));
219
+ const sitesIn = own(manifest, 'sites');
220
+ if (own(manifest, 'schema_version') !== MANIFEST_SCHEMA_VERSION || !safeArrayIsArray(sitesIn)) return null;
221
+ const sites = [];
222
+ let bytes = 0;
223
+ for (let index = 0; index < sitesIn.length; index++) {
224
+ const site = sitesIn[index];
225
+ const strings = ['probe_key', 'site_id', 'source_path', 'qualified_name', 'probe_kind', 'owner_key', 'structural_key'];
226
+ const values = [];
227
+ for (let position = 0; position < strings.length; position++) safeArrayPush(values, own(site, strings[position]));
228
+ let valid = true;
229
+ for (let position = 0; position < values.length; position++) if (typeof values[position] !== 'string') valid = false;
230
+ if (!valid) continue;
231
+ const label = own(site, 'label');
232
+ const line = own(site, 'line');
233
+ const column = own(site, 'column');
234
+ const allowed = own(site, 'allowed_fields');
235
+ const fields = [];
236
+ if (safeArrayIsArray(allowed)) {
237
+ for (let position = 0; position < allowed.length && fields.length < CONTRACT.SERVER_FIELDS_PER_SITE; position++) {
238
+ const field = allowed[position];
239
+ if (typeof field === 'string' && safeRegExpTest(FIELD_NAME, field)) safeArrayPush(fields, field);
240
+ }
241
+ }
242
+ const declared = own(site, 'settings');
243
+ let settings = null;
244
+ if (safeArrayIsArray(declared) && declared.length >= 1 && declared.length <= CONTRACT.SETTINGS_MAX_DOMAIN + 2) {
245
+ settings = [];
246
+ for (let position = 0; position < declared.length; position++) {
247
+ if (!validSettingLiteral(declared[position])) {
248
+ settings = null;
249
+ break;
250
+ }
251
+ safeArrayPush(settings, declared[position]);
252
+ }
253
+ }
254
+ // A telemetry-owned, null-prototype record of primitives: what a flush
255
+ // serializes for the site (ServerSiteV5) without any object reaching
256
+ // JSON.stringify.
257
+ const metadata = safeObjectCreate(null);
258
+ metadata.probeKey = values[0];
259
+ metadata.siteId = values[1];
260
+ metadata.sourcePath = values[2];
261
+ metadata.qualifiedName = values[3];
262
+ metadata.probeKind = values[4];
263
+ metadata.ownerKey = values[5];
264
+ metadata.structuralKey = values[6];
265
+ metadata.label = typeof label === 'string' && label.length > 0 ? label : null;
266
+ metadata.line = safeNumberIsSafeInteger(line) && line >= 1 ? line : null;
267
+ metadata.column = metadata.line !== null && safeNumberIsSafeInteger(column) && column >= 0 ? column : null;
268
+ metadata.fields = fields;
269
+ metadata.settings = settings;
270
+ safeArrayPush(sites, metadata);
271
+ bytes += SITE_BYTES;
272
+ for (let position = 0; position < values.length; position++) bytes += stringBytes(values[position]);
273
+ if (metadata.label !== null) bytes += stringBytes(metadata.label);
274
+ for (let position = 0; position < fields.length; position++) bytes += 8 + stringBytes(fields[position]);
275
+ if (settings !== null) {
276
+ bytes += MAP_BYTES;
277
+ for (let position = 0; position < settings.length; position++) {
278
+ bytes += ENTRY_BYTES + stringBytes(`setting:${literalJson(settings[position])}`);
279
+ }
431
280
  }
432
- const entryFile = safeArrayIsArray(manifest.instrumented_files)
433
- ? safeArrayFind(manifest.instrumented_files, file => file.output_path === manifest.entry)
434
- : null;
435
- return {
436
- sites,
437
- buildId: safeSegment(manifest.build_id, 'unknown'),
438
- entrySourcePath: typeof entryFile?.source_path === 'string' ? entryFile.source_path : null,
439
- };
440
- } catch (error) {
441
- debug('instrumentation manifest could not be read; telemetry stayed disabled', error);
442
- return { sites: new SafeMap(), buildId: 'unknown', entrySourcePath: null };
443
281
  }
282
+ return { sites, bytes, buildId: safeSegment(own(manifest, 'build_id'), 'unknown') };
444
283
  }
445
284
 
446
- function installRuntime(options = {}) {
447
- if (installed) return activeControl ?? { enabled: false, reason: 'already-installed' };
285
+ function installRuntime() {
286
+ if (installed) return activeControl ?? installResult ?? { enabled: false, reason: 'already-installed' };
448
287
  installed = true;
449
288
 
450
289
  const endpoint = validEndpoint(process.env.HAYSTACK_TELEMETRY_ENDPOINT ?? '');
451
290
  const token = process.env.HAYSTACK_TELEMETRY_TOKEN;
452
- if (process.env.HAYSTACK_TELEMETRY !== '1' || !endpoint || !token || !safeFetch || !safeAbortTimeout) {
291
+ if (process.env.HAYSTACK_TELEMETRY !== '1' || !endpoint || !token || CONTRACT === null) {
453
292
  return { enabled: false, reason: 'not-configured' };
454
293
  }
455
- if (CONTROL_CHANNEL in processHost) {
456
- debug('telemetry control name is already owned; telemetry stayed disabled');
457
- return { enabled: false, reason: 'global-conflict' };
458
- }
294
+ if (CONTROL_CHANNEL in processHost) return { enabled: false, reason: 'global-conflict' };
459
295
 
460
- // The manifest-derived build identity is part of every exact DDB family ID.
461
- // Do not accept a runtime override that could merge unrelated builds.
462
- const instrumentation = loadInstrumentation();
463
- const buildId = instrumentation.buildId;
464
- const sitesByProbe = instrumentation.sites;
465
- const sitesById = new SafeMap();
466
- const probeStates = new SafeMap();
467
- const siteValues = mapValuesArray(sitesByProbe);
468
- for (let index = 0; index < siteValues.length; index++) {
469
- const metadata = siteValues[index];
470
- safeMapSet(sitesById, metadata.siteId, metadata);
471
- safeMapSet(probeStates, metadata.probeKey, {
472
- metadata,
473
- calls: 0,
474
- saturated: false,
475
- });
296
+ // The manifest-derived build identity is part of every raw flush key. Do not
297
+ // accept a runtime override that could merge unrelated builds.
298
+ let instrumentation;
299
+ try {
300
+ instrumentation = loadInstrumentation();
301
+ } catch {
302
+ instrumentation = null;
303
+ }
304
+ if (!instrumentation || instrumentation.sites.length === 0) {
305
+ return { enabled: false, reason: 'missing-instrumentation-manifest' };
476
306
  }
477
- if (safeMapSize(sitesByProbe) === 0) return { enabled: false, reason: 'missing-instrumentation-manifest' };
307
+ const MAX_BYTES = CONTRACT.SERVER_RUNTIME_MAX_BYTES;
308
+ // Site metadata is kept for the life of the process; a build whose sites
309
+ // alone would take half the cap leaves too little to count with, and
310
+ // installs nothing.
311
+ if (instrumentation.bytes > MAX_BYTES / 2) {
312
+ return { enabled: false, reason: 'instrumentation-exceeds-memory-cap' };
313
+ }
314
+ const { buildId } = instrumentation;
478
315
  const runId = safeSegment(
479
316
  process.env.HAYSTACK_TELEMETRY_RUN_ID,
480
317
  `${safeDateNow()}-${process.pid}-${randomBytes(4).toString('hex')}`,
@@ -483,987 +320,691 @@ function installRuntime(options = {}) {
483
320
  const flushIntervalMs = safeNumberIsSafeInteger(intervalRequested) && intervalRequested >= 100
484
321
  ? intervalRequested
485
322
  : DEFAULT_FLUSH_INTERVAL_MS;
486
- const requestedSiteObservations = safeNumberParseInt(
487
- process.env.HAYSTACK_TELEMETRY_SITE_OBSERVATIONS ?? '',
488
- 10,
489
- );
490
- const siteObservationLimit = safeNumberIsSafeInteger(requestedSiteObservations) &&
491
- requestedSiteObservations >= 1 && requestedSiteObservations <= MAX_SITE_OBSERVATIONS
492
- ? requestedSiteObservations
493
- : DEFAULT_SITE_OBSERVATIONS;
494
- const requestedProcessProbeObservations = safeNumberParseInt(
495
- process.env.HAYSTACK_TELEMETRY_PROCESS_PROBE_OBSERVATIONS ?? '',
496
- 10,
497
- );
498
- const processProbeObservationLimit = safeNumberIsSafeInteger(requestedProcessProbeObservations) &&
499
- requestedProcessProbeObservations >= 1 &&
500
- requestedProcessProbeObservations <= MAX_PROCESS_PROBE_OBSERVATIONS
501
- ? requestedProcessProbeObservations
502
- : DEFAULT_PROCESS_PROBE_OBSERVATIONS;
503
- const requestedProcessSampleRate = safeNumberParseInt(
504
- process.env.HAYSTACK_TELEMETRY_PROCESS_SAMPLE_RATE ?? '',
505
- 10,
506
- );
507
- const processSampleRate = safeNumberIsSafeInteger(requestedProcessSampleRate) &&
508
- requestedProcessSampleRate >= 0 && requestedProcessSampleRate <= MAX_ACTION_SAMPLE_RATE
509
- ? requestedProcessSampleRate
510
- : DEFAULT_PROCESS_SAMPLE_RATE;
511
- const requestedActionSampleRate = safeNumberParseInt(
512
- process.env.HAYSTACK_TELEMETRY_ACTION_SAMPLE_RATE ?? '',
513
- 10,
514
- );
515
- const actionSampleRate = safeNumberIsSafeInteger(requestedActionSampleRate) &&
516
- requestedActionSampleRate >= 1 && requestedActionSampleRate <= MAX_ACTION_SAMPLE_RATE
517
- ? requestedActionSampleRate
518
- : DEFAULT_ACTION_SAMPLE_RATE;
519
323
 
520
- let enabled = true;
521
- let collecting = true;
522
- let processProbeObservations = 0;
324
+ const SAMPLE_AFTER_CALLS = CONTRACT.SERVER_SAMPLE_AFTER_CALLS;
325
+ const SAMPLE_RATE = CONTRACT.SERVER_SAMPLE_RATE;
326
+ // The draw stays in integer arithmetic: a power-of-two rate keeps the low
327
+ // bits (a mask); any other rate takes an unsigned remainder.
328
+ const SAMPLE_MASK = (SAMPLE_RATE & (SAMPLE_RATE - 1)) === 0 ? SAMPLE_RATE - 1 : -1;
329
+ const OBSERVATIONS_PER_SECOND = CONTRACT.SERVER_OBSERVATIONS_PER_SECOND;
330
+ const SEND_TIMEOUT_MS = CONTRACT.SERVER_SEND_TIMEOUT_MS;
331
+ const EXIT_FLUSH_MS = CONTRACT.SERVER_EXIT_FLUSH_MS;
332
+ const C = CONTRACT.categories;
333
+ const OVERFLOW = C.overflow;
334
+ const FIELD_STATE_PRESENT = 0;
335
+ const FIELD_STATE_MISSING = 1;
336
+ const FIELD_STATE_NULL = 2;
337
+ const FIELD_STATE_UNDEFINED = 3;
338
+ const FIELD_STATE_OPAQUE = 4;
339
+ const FIELD_STATES = CONTRACT.SERVER_FIELD_STATES;
340
+ const KIND_BRANCH = 0;
341
+ const KIND_VALUE = 1;
342
+
343
+ let counting = true;
344
+ let offReason = null;
523
345
  let sequence = 0;
524
346
  let consecutiveFailures = 0;
525
- let currentShape = new SafeMap();
526
- let currentBranch = new SafeMap();
527
- let currentExecutions = new SafeMap();
528
- let currentExecutionBytes = 0;
529
- let omittedExecutionFamilyObservations = 0;
530
- let sampledActions = 0;
531
- let unsampledActions = 0;
532
- let currentOmissions = new SafeMap();
347
+ let budgetUsed = 0;
533
348
  let pending = null;
534
349
  let pendingQueue = [];
535
350
  let flushInFlight = null;
536
- let beforeExitStarted = false;
537
- const actionStorage = new AsyncLocalStorage();
538
- const httpActions = new SafeWeakMap();
539
- let actionSequence = 0;
540
- const actionSeed = hashSeed(`${buildId}:${runId}`);
541
- let processSelected = true;
542
- let hasChildActions = false;
543
-
544
- function shouldSampleAction(actionKind) {
545
- const sequence = actionSequence++;
546
- if (actionKind !== 'process' && !processSelected) {
547
- unsampledActions++;
548
- return false;
549
- }
550
- if (actionKind !== 'process' && sequence <= SAMPLE_FIRST_ACTIONS) {
551
- sampledActions++;
552
- return true;
351
+ let spoolReplayed = false;
352
+ let exiting = false;
353
+ let exitDeadline = 0;
354
+ let spooled = false;
355
+ let currentSend = null;
356
+
357
+ // Every retained byte, by what holds it (rule 13b: the cap covers them all).
358
+ const memory = {
359
+ sites: instrumentation.bytes,
360
+ fieldKeys: 0,
361
+ counters: 0,
362
+ serializing: 0,
363
+ queued: 0,
364
+ };
365
+ const used = () => memory.sites + memory.fieldKeys + memory.counters + memory.serializing + memory.queued;
366
+ const COUNTER_CAP = (MAX_BYTES - memory.sites) * COUNTER_SHARE;
367
+ const allocatable = bytes => used() + bytes <= MAX_BYTES - RESERVE_BYTES;
368
+
369
+ const statesByProbe = new SafeMap();
370
+ const states = [];
371
+ for (let index = 0; index < instrumentation.sites.length; index++) {
372
+ const metadata = instrumentation.sites[index];
373
+ let settings = null;
374
+ if (metadata.settings !== null) {
375
+ // Map keys compare by SameValueZero: 'dark' and 1 never meet, and a
376
+ // lookup never coerces the customer's value.
377
+ settings = new SafeMap();
378
+ for (let position = 0; position < metadata.settings.length; position++) {
379
+ const literal = metadata.settings[position];
380
+ safeMapSet(settings, literal, `${CONTRACT.SERVER_SETTING_CATEGORY_PREFIX}${literalJson(literal)}`);
381
+ }
553
382
  }
554
- const rate = actionKind === 'process' ? processSampleRate : actionSampleRate;
555
- const sampled = rate === 1 || (rate > 1 &&
556
- ((actionSeed + safeMathImul(sequence + 1, 0x9e3779b1)) >>> 0) % rate === 0);
557
- if (actionKind === 'process') processSelected = sampled;
558
- if (sampled) sampledActions++;
559
- else unsampledActions++;
560
- return sampled;
561
- }
562
-
563
- function startAction(actionKind, actionSiteId, actionMethod) {
564
- if (!shouldSampleAction(actionKind)) return SUPPRESSED_ACTION;
565
- return {
566
- suppressed: false,
567
- actionKind,
568
- actionSiteId,
569
- actionMethod,
570
- events: [],
571
- eventBytes: 0,
572
- truncated: false,
573
- omittedEvents: 0,
574
- finalized: false,
575
- outcome: null,
576
- terminalError: 'none',
383
+ const state = {
384
+ metadata,
385
+ kind: metadata.probeKind === 'condition' ? KIND_BRANCH : KIND_VALUE,
386
+ probeName: metadata.probeKind === 'condition'
387
+ ? '__hstBranch'
388
+ : metadata.probeKind === 'throw' ? '__hstThrow' : '__hstValue',
389
+ settings,
390
+ // Lifetime calls (the census is the process's first SERVER_SAMPLE_AFTER_CALLS); a window's calls are seen
391
+ // minus the mark its last flush left.
392
+ seen: 0,
393
+ flushedSeen: 0,
394
+ index,
395
+ skipped: 0,
396
+ census: null,
397
+ sampled: null,
398
+ // Bytes this site's window counters hold (part of memory.counters).
399
+ bytes: 0,
577
400
  };
401
+ safeMapSet(statesByProbe, metadata.probeKey, state);
402
+ safeArrayPush(states, state);
578
403
  }
579
404
 
580
- function actionForProbe() {
581
- const action = hasChildActions ? safeActionGetStore(actionStorage) : processAction;
582
- return action && !action.finalized ? action : null;
405
+ // Per-site PRNG state in an Int32Array: a 32-bit state kept on an object
406
+ // would be boxed as a heap number on every draw.
407
+ const randomState = new Int32Array(states.length);
408
+ for (let index = 0; index < states.length; index++) {
409
+ randomState[index] = hashSeed(`${runId}:${states[index].metadata.siteId}`) | 0;
410
+ }
411
+ memory.sites += states.length * 4;
412
+
413
+ // Field keys (`.<field>:<state>`), built on first use and shared by every
414
+ // site declaring the same field.
415
+ const fieldKeysByName = new SafeMap();
416
+ function fieldKeys(field) {
417
+ let keys = safeMapGet(fieldKeysByName, field);
418
+ if (keys !== undefined) return keys;
419
+ const cost = ENTRY_BYTES + 5 * stringBytes(`.${field}:undefined`);
420
+ if (!allocatable(cost)) return null;
421
+ keys = [`.${field}:${FIELD_STATES[0]}`, `.${field}:${FIELD_STATES[1]}`, `.${field}:${FIELD_STATES[2]}`,
422
+ `.${field}:${FIELD_STATES[3]}`, `.${field}:${FIELD_STATES[4]}`];
423
+ safeMapSet(fieldKeysByName, field, keys);
424
+ memory.fieldKeys += cost;
425
+ return keys;
583
426
  }
584
427
 
585
- function omitExecutionEvents(action, count = 1) {
586
- if (!action || action.suppressed || count < 1) return;
587
- action.truncated = true;
588
- action.omittedEvents = safeMathMin(MAX_SAFE_COUNT, action.omittedEvents + count);
428
+ // The fields' states and keys for the call being classified, reused across calls.
429
+ const fieldScratch = new Uint8Array(CONTRACT.SERVER_FIELDS_PER_SITE);
430
+ const keyScratch = [];
431
+
432
+ function turnOff(reason) {
433
+ if (!counting) return;
434
+ counting = false;
435
+ offReason = reason;
436
+ safeClearInterval(timer);
437
+ safeClearInterval(budgetTimer);
438
+ if (currentSend !== null) currentSend();
439
+ pending = null;
440
+ pendingQueue = [];
441
+ for (let index = 0; index < states.length; index++) {
442
+ const state = states[index];
443
+ state.flushedSeen = state.seen;
444
+ state.skipped = 0;
445
+ state.census = null;
446
+ state.sampled = null;
447
+ state.bytes = 0;
448
+ }
449
+ memory.counters = 0;
450
+ memory.serializing = 0;
451
+ memory.queued = 0;
589
452
  }
590
453
 
591
- function currentAction() {
592
- const action = actionForProbe();
593
- return action && !action.suppressed ? action : null;
454
+ function sizeIndex(size) {
455
+ return size === 0 ? 0 : size === 1 ? 1 : size <= 5 ? 2 : size <= 20 ? 3 : size <= 100 ? 4 : 5;
594
456
  }
595
457
 
596
- function passThrough(_site, value) {
597
- return value;
458
+ function numberCategory(value) {
459
+ if (value !== value || value === Infinity || value === -Infinity) return C.numberNonfinite;
460
+ if (value === 0) return C.numberZero;
461
+ const negative = value < 0;
462
+ const magnitude = negative ? -value : value;
463
+ const index = magnitude < 1 ? 0 : magnitude < 10 ? 1 : magnitude < 100 ? 2 : magnitude < 1000 ? 3 : 4;
464
+ return negative ? C.numberNegative[index] : C.numberPositive[index];
598
465
  }
599
466
 
600
- function addExecutionEvent(site, kind, scope, outcome) {
601
- const action = currentAction();
602
- if (!action) return;
603
- if (action.truncated) {
604
- omitExecutionEvents(action);
605
- return;
606
- }
607
- const metadata = safeMapGet(sitesByProbe, site);
608
- if (!metadata) return;
609
- const previous = action.events[action.events.length - 1];
610
- if (previous && previous.siteId === metadata.siteId && previous.kind === kind &&
611
- previous.scope === scope && previous.outcome === outcome) {
612
- previous.observations++;
613
- return;
614
- }
615
- const event = {
616
- siteId: metadata.siteId,
617
- kind,
618
- scope,
619
- outcome,
620
- observations: 1,
621
- };
622
- const eventBytes = safeBufferByteLength(safeJsonStringify({
623
- ...event,
624
- sourcePath: metadata.sourcePath,
625
- qualifiedName: metadata.qualifiedName,
626
- probeKind: metadata.probeKind,
627
- }), 'utf8');
628
- if (action.events.length >= MAX_EXECUTION_EVENTS ||
629
- action.eventBytes + eventBytes > MAX_EXECUTION_ACTION_BYTES) {
630
- action.truncated = true;
631
- omitExecutionEvents(action);
632
- return;
467
+ /** Rule 9(b2): a field's state through property descriptors only, up the
468
+ * prototype chain to Object.prototype; an accessor or a proxy anywhere on
469
+ * the way is opaque, never invoked. */
470
+ function fieldState(object, field) {
471
+ let holder = object;
472
+ for (let depth = 0; depth < 8; depth++) {
473
+ const descriptor = safeGetOwnPropertyDescriptor(holder, field);
474
+ if (descriptor !== undefined) {
475
+ if (!safeHasOwn(descriptor, 'value')) return FIELD_STATE_OPAQUE;
476
+ const value = descriptor.value;
477
+ return value === undefined ? FIELD_STATE_UNDEFINED : value === null ? FIELD_STATE_NULL : FIELD_STATE_PRESENT;
478
+ }
479
+ const prototype = safeGetPrototypeOf(holder);
480
+ if (prototype === null || prototype === ObjectPrototype) return FIELD_STATE_MISSING;
481
+ if (safeIsProxy(prototype)) return FIELD_STATE_OPAQUE;
482
+ holder = prototype;
633
483
  }
634
- safeArrayPush(action.events, event);
635
- action.eventBytes += eventBytes;
484
+ return FIELD_STATE_OPAQUE;
636
485
  }
637
486
 
638
- function finalizeAction(action, outcome) {
639
- if (!action || action.suppressed || action.finalized) return;
640
- action.finalized = true;
641
- action.outcome = outcome;
642
- if (action.events.length === 0) {
643
- if (action.omittedEvents > 0 && omittedExecutionFamilyObservations < MAX_SAFE_COUNT) {
644
- omittedExecutionFamilyObservations++;
487
+ /** Rule 9(b), (b2), (c): exactly one value category per call, never a value
488
+ * except an approved literal, and no customer code run to find it. */
489
+ function valueCategory(state, value) {
490
+ if (value === undefined) return C.undefined;
491
+ if (value === null) return C.null;
492
+ const type = typeof value;
493
+ if (state.settings !== null) {
494
+ if (type === 'string' || type === 'number' || type === 'boolean') {
495
+ const literal = safeMapGet(state.settings, value);
496
+ return literal === undefined ? C.other : literal;
645
497
  }
646
- return;
647
- }
648
- const sketch = {
649
- actionKind: action.actionKind,
650
- actionSiteId: action.actionSiteId,
651
- actionMethod: action.actionMethod,
652
- outcome,
653
- terminalError: action.terminalError,
654
- truncated: action.truncated,
655
- omittedEvents: action.omittedEvents,
656
- events: action.events,
657
- };
658
- const familyId = stableIdentity('execution-local-family', [canonicalJson(sketch)]);
659
- const existing = safeMapGet(currentExecutions, familyId);
660
- if (existing) {
661
- existing.observations++;
662
- return;
663
- }
664
- const sketchBytes = action.eventBytes + safeBufferByteLength(safeJsonStringify(sketch), 'utf8');
665
- if (safeMapSize(currentExecutions) >= MAX_EXECUTION_FAMILIES ||
666
- currentExecutionBytes + sketchBytes > MAX_EXECUTION_BYTES) {
667
- omittedExecutionFamilyObservations++;
668
- return;
498
+ return (type === 'object' || type === 'function') && safeIsProxy(value) ? C.opaque : C.other;
669
499
  }
670
- safeMapSet(currentExecutions, familyId, { familyId, observations: 1, ...sketch });
671
- currentExecutionBytes += sketchBytes;
500
+ if (type === 'string') return C.string[sizeIndex(value.length)];
501
+ if (type === 'number') return numberCategory(value);
502
+ if (type === 'boolean') return C.boolean;
503
+ if (type === 'bigint') return C.bigint;
504
+ if (type === 'symbol') return C.symbol;
505
+ if (safeIsProxy(value)) return C.opaque;
506
+ if (type === 'function') return C.function;
507
+ // A genuine array's length is an own data property; Map and Set sizes come
508
+ // from the built-in getters captured above, never the value's own.
509
+ if (safeArrayIsArray(value)) return C.array[sizeIndex(value.length)];
510
+ if (safeIsMap(value)) return C.map[sizeIndex(safeMapSize(value))];
511
+ if (safeIsSet(value)) return C.set[sizeIndex(safeSetSize(value))];
512
+ if (safeIsNativeError(value)) return C.error;
513
+ return C.object;
672
514
  }
673
515
 
674
- let processAction = startAction(
675
- 'process',
676
- stableIdentity('action-site', [instrumentation.entrySourcePath || 'unknown-entry']),
677
- 'NONE',
678
- );
679
- safeActionEnterWith(actionStorage, processAction);
516
+ function entryCost(key) {
517
+ return ENTRY_BYTES + key.length * 2;
518
+ }
680
519
 
681
- function continueProcessAction() {
682
- if (processAction.suppressed) return;
683
- const previous = processAction;
684
- const active = safeActionGetStore(actionStorage);
685
- processAction = {
686
- suppressed: false,
687
- actionKind: processAction.actionKind,
688
- actionSiteId: processAction.actionSiteId,
689
- actionMethod: processAction.actionMethod,
690
- events: [],
691
- eventBytes: 0,
692
- truncated: false,
693
- omittedEvents: 0,
694
- finalized: false,
695
- outcome: null,
696
- terminalError: 'none',
697
- };
698
- if (!hasChildActions || !active || active === previous) {
699
- safeActionEnterWith(actionStorage, processAction);
520
+ function bump(state, counters, key) {
521
+ const existing = safeMapGet(counters, key);
522
+ if (existing === undefined) {
523
+ const cost = entryCost(key);
524
+ state.bytes += cost;
525
+ memory.counters += cost;
526
+ safeMapSet(counters, key, 1);
527
+ } else {
528
+ safeMapSet(counters, key, existing + 1);
700
529
  }
701
530
  }
702
531
 
703
- try {
704
- const requestStart = diagnosticsChannel.channel('http.server.request.start');
705
- const responseFinish = diagnosticsChannel.channel('http.server.response.finish');
706
- requestStart.subscribe((message) => {
707
- try {
708
- hasChildActions = true;
709
- const action = startAction(
710
- 'http-server',
711
- stableIdentity('action-site', ['node:http-server']),
712
- httpMethod(message?.request?.method),
713
- );
714
- safeActionEnterWith(actionStorage, action);
715
- if (message?.response && typeof message.response === 'object') {
716
- safeWeakMapSet(httpActions, message.response, action);
532
+ function classify(state, value, census) {
533
+ let counters = census ? state.census : state.sampled;
534
+ if (counters === null) {
535
+ if (!allocatable(MAP_BYTES + ENTRY_BYTES)) {
536
+ // Not even an overflow counter fits: the call is unclassified.
537
+ state.skipped++;
538
+ return;
539
+ }
540
+ counters = new SafeMap();
541
+ state.bytes += MAP_BYTES;
542
+ memory.counters += MAP_BYTES;
543
+ if (census) state.census = counters;
544
+ else state.sampled = counters;
545
+ }
546
+ let category;
547
+ let fieldCount = 0;
548
+ try {
549
+ if (state.kind === KIND_BRANCH) {
550
+ // ToBoolean never runs customer code.
551
+ category = value ? C.true : C.false;
552
+ } else {
553
+ category = valueCategory(state, value);
554
+ const fields = state.metadata.fields;
555
+ if (category === C.object && fields.length > 0) {
556
+ fieldCount = fields.length;
557
+ for (let index = 0; index < fieldCount; index++) {
558
+ fieldScratch[index] = fieldState(value, fields[index]);
559
+ keyScratch[index] = fieldKeys(fields[index]);
560
+ }
717
561
  }
718
- } catch (error) {
719
- debug('HTTP action start failed open', error);
720
562
  }
721
- });
722
- responseFinish.subscribe((message) => {
723
- try {
724
- const action = message?.response && typeof message.response === 'object'
725
- ? safeWeakMapGet(httpActions, message.response)
726
- : null;
727
- const response = message?.response;
728
- const statusDescriptor = response && typeof response === 'object' && !safeIsProxy(response)
729
- ? safeGetOwnPropertyDescriptor(response, 'statusCode')
730
- : null;
731
- const status = statusDescriptor && safeHasOwn(statusDescriptor, 'value')
732
- ? safeNumber(statusDescriptor.value)
733
- : NaN;
734
- const outcome = !safeNumberIsFinite(status)
735
- ? 'completed'
736
- : status >= 500
737
- ? 'server-error'
738
- : status >= 400
739
- ? 'client-error'
740
- : 'success';
741
- finalizeAction(action, outcome);
742
- } catch (error) {
743
- debug('HTTP action finish failed open', error);
563
+ } catch {
564
+ // An exotic object refused inspection; it is opaque, and nothing escapes.
565
+ category = state.kind === KIND_BRANCH ? (value ? C.true : C.false) : C.opaque;
566
+ fieldCount = 0;
567
+ }
568
+ // Past the counters' share of the cap (or with a field key that could not
569
+ // be built), a call that needs a counter this window has not allocated is
570
+ // counted as overflow alone, its fields unrecorded, so every classified
571
+ // call still adds exactly one value category.
572
+ let complete = true;
573
+ for (let index = 0; complete && index < fieldCount; index++) complete = keyScratch[index] !== null;
574
+ if (complete && memory.counters >= COUNTER_CAP) {
575
+ complete = safeMapGet(counters, category) !== undefined;
576
+ for (let index = 0; complete && index < fieldCount; index++) {
577
+ complete = safeMapGet(counters, keyScratch[index][fieldScratch[index]]) !== undefined;
744
578
  }
745
- });
746
- } catch (error) {
747
- debug('HTTP action correlation unavailable', error);
748
- }
749
-
750
- function recordOmission(site, field) {
751
- const current = safeMapGet(currentOmissions, site) || {
752
- counterObservations: 0,
753
- probeObservations: 0,
754
- };
755
- current[field]++;
756
- safeMapSet(currentOmissions, site, current);
757
- }
758
-
759
- function disable(reason, error) {
760
- enabled = false;
761
- safeClearInterval(timer);
762
- debug(`disabled after ${reason}`, error);
763
- }
764
-
765
- function sampleWeight(state) {
766
- if (processProbeObservations >= processProbeObservationLimit) {
767
- // Attribute the one observation that exhausted the process-wide budget.
768
- // Later probes are deliberately not inspected or counted: the sampler
769
- // version declares that this is a bounded, lossy collection mode.
770
- recordOmission(state.metadata.probeKey, 'probeObservations');
771
- collecting = false;
772
- return -2;
773
579
  }
774
- processProbeObservations++;
775
- const calls = ++state.calls;
776
- if (calls > siteObservationLimit) {
777
- state.saturated = true;
778
- return -1;
580
+ if (!complete) {
581
+ const existing = safeMapGet(counters, OVERFLOW);
582
+ if (existing !== undefined) safeMapSet(counters, OVERFLOW, existing + 1);
583
+ else if (allocatable(entryCost(OVERFLOW))) bump(state, counters, OVERFLOW);
584
+ else state.skipped++;
585
+ return;
779
586
  }
780
- return 1;
587
+ bump(state, counters, category);
588
+ for (let index = 0; index < fieldCount; index++) bump(state, counters, keyScratch[index][fieldScratch[index]]);
589
+ if (used() > MAX_BYTES) turnOff('memory cap');
781
590
  }
782
591
 
783
- function increment(map, key, weight) {
784
- const existing = safeMapGet(map, key);
785
- if (existing === undefined && safeMapSize(map) >= MAX_COUNTER_KEYS) {
786
- recordOmission(safeStringSlice(key, 0, safeStringIndexOf(key, '|')), 'counterObservations');
592
+ /** The rare path of a call: past the draw (or in the census), within the
593
+ * second's budget, classified; never throws into the probe. */
594
+ function sample(state, value, census) {
595
+ if (budgetUsed >= OBSERVATIONS_PER_SECOND) {
596
+ state.skipped++;
787
597
  return;
788
598
  }
789
- safeMapSet(map, key, (existing ?? 0) + weight);
790
- }
791
-
792
- function observeState(state, value, fields) {
793
- // Saturation is deliberately lossy: the first over-limit observation
794
- // records an explicit saturation marker, then this probe becomes a
795
- // constant-time pass-through. Counting every later omission caused severe
796
- // amplification in customer hot loops while adding no new shape evidence.
797
- if (!enabled || !collecting || state.saturated) return value;
599
+ budgetUsed++;
798
600
  try {
799
- const site = state.metadata.probeKey;
800
- const action = actionForProbe();
801
- if (action?.suppressed) return value;
802
- const probeKind = state.metadata.probeKind;
803
- const executionValue = action && (probeKind === 'parameter' || probeKind === 'return');
804
- const weight = sampleWeight(state);
805
- if (weight === -2) {
806
- if (executionValue) {
807
- const skippedEvents = value !== null && typeof value === 'object' && !safeArrayIsArray(value)
808
- ? 1 + safeMathMin(fields.length, 48)
809
- : 1;
810
- omitExecutionEvents(action, skippedEvents);
811
- }
812
- return value;
813
- }
814
- if (weight < 0) {
815
- if (weight === -1) increment(currentShape, `${site}|__saturated__|info`, 1);
816
- if (executionValue) {
817
- const skippedEvents = value !== null && typeof value === 'object' && !safeArrayIsArray(value)
818
- ? 1 + safeMathMin(fields.length, 48)
819
- : 1;
820
- omitExecutionEvents(action, skippedEvents);
821
- }
822
- return value;
823
- }
824
- const fieldEventCount = safeArrayIsArray(fields) && value !== null &&
825
- typeof value === 'object' && !safeArrayIsArray(value)
826
- ? safeMathMin(fields.length, 48)
827
- : 0;
828
- const correlated = executionValue && !action.truncated;
829
- if (executionValue && action.truncated) omitExecutionEvents(action, 1 + fieldEventCount);
830
- if (weight > 0) {
831
- const selfOutcome = value === null ? 'null' : value === undefined ? 'undefined' : 'ok';
832
- increment(currentShape, `${site}|__self__|${selfOutcome}`, weight);
833
- increment(currentShape, `${site}|__self__|shape:${valueShape(value)}`, weight);
834
- }
835
- if (correlated && probeKind === 'parameter') {
836
- addExecutionEvent(site, 'input', 'whole-value', `shape:${valueShape(value)}`);
837
- } else if (correlated && probeKind === 'return') {
838
- addExecutionEvent(site, 'output', 'whole-value', `shape:${valueShape(value)}`);
839
- }
601
+ classify(state, value, census);
602
+ } catch {
603
+ // Nothing a probe does may reach the application.
604
+ }
605
+ }
840
606
 
841
- if (safeArrayIsArray(fields) && value !== null && typeof value === 'object' && !safeArrayIsArray(value)) {
842
- const proxyValue = safeIsProxy(value);
843
- for (let fieldIndex = 0; fieldIndex < fields.length && fieldIndex < 48; fieldIndex++) {
844
- const field = fields[fieldIndex];
845
- // Manifest loading already validates the static field list. Avoid
846
- // mutable RegExp/String prototype calls in this hot probe path.
847
- if (typeof field !== 'string') continue;
848
- const ordinal = fieldIndex + 1;
849
- const fieldScope = `named-field-${ordinal < 10 ? `0${ordinal}` : ordinal}`;
850
- if (proxyValue) {
851
- if (weight > 0) increment(currentShape, `${site}|${field}|uninspectable`, weight);
852
- if (correlated) {
853
- addExecutionEvent(site, probeKind === 'parameter' ? 'input' : 'output', fieldScope, 'uninspectable');
854
- }
855
- continue;
856
- }
857
- let descriptor;
858
- try {
859
- descriptor = safeGetOwnPropertyDescriptor(value, field);
860
- } catch {
861
- if (weight > 0) increment(currentShape, `${site}|${field}|uninspectable`, weight);
862
- if (correlated) {
863
- addExecutionEvent(site, probeKind === 'parameter' ? 'input' : 'output', fieldScope, 'uninspectable');
864
- }
865
- continue;
866
- }
867
- if (!descriptor) {
868
- if (weight > 0) increment(currentShape, `${site}|${field}|absent`, weight);
869
- if (correlated) {
870
- addExecutionEvent(site, probeKind === 'parameter' ? 'input' : 'output', fieldScope, 'absent');
871
- }
872
- } else if (!safeHasOwn(descriptor, 'value')) {
873
- if (weight > 0) increment(currentShape, `${site}|${field}|accessor`, weight);
874
- if (correlated) {
875
- addExecutionEvent(site, probeKind === 'parameter' ? 'input' : 'output', fieldScope, 'accessor');
876
- }
877
- } else {
878
- const fieldValue = descriptor.value;
879
- const outcome = fieldValue === null ? 'null' : fieldValue === undefined ? 'undefined' : 'ok';
880
- if (weight > 0) {
881
- increment(currentShape, `${site}|${field}|${outcome}`, weight);
882
- increment(currentShape, `${site}|${field}|shape:${valueShape(fieldValue)}`, weight);
883
- }
884
- if (correlated) {
885
- addExecutionEvent(
886
- site,
887
- probeKind === 'parameter' ? 'input' : 'output',
888
- fieldScope,
889
- `shape:${valueShape(fieldValue)}`,
890
- );
891
- }
892
- }
893
- }
607
+ /** One site's probe: rule 9(a)'s single increment, then the census or a
608
+ * Bernoulli draw (xorshift32, decorrelated from any predicate's period,
609
+ * where a fixed stride would alias with it). */
610
+ function siteProbe(state) {
611
+ const index = state.index;
612
+ return (value) => {
613
+ if (!counting) return value;
614
+ if (++state.seen > SAMPLE_AFTER_CALLS) {
615
+ let random = randomState[index];
616
+ random ^= random << 13;
617
+ random ^= random >>> 17;
618
+ random ^= random << 5;
619
+ randomState[index] = random;
620
+ if (SAMPLE_MASK >= 0 ? (random & SAMPLE_MASK) !== 0 : (random >>> 0) % SAMPLE_RATE !== 0) return value;
621
+ sample(state, value, false);
622
+ } else {
623
+ sample(state, value, true);
894
624
  }
895
- } catch (error) {
896
- // A hostile Proxy or exotic object must never affect the host app.
897
- debug('value probe failed open', error);
898
- }
899
- return value;
625
+ return value;
626
+ };
900
627
  }
901
628
 
902
- function observe(site, value, fields) {
903
- const state = safeMapGet(probeStates, site);
904
- return state ? observeState(state, value, fields) : value;
629
+ /** Sites that counted a call since the last flush (a scan off the hot path). */
630
+ function activeStates() {
631
+ const active = [];
632
+ for (let index = 0; index < states.length; index++) {
633
+ const state = states[index];
634
+ if (state.seen !== state.flushedSeen || state.skipped > 0) safeArrayPush(active, state);
635
+ }
636
+ return active;
905
637
  }
906
638
 
907
- function branchState(state, value) {
908
- if (!enabled || !collecting || state.saturated) return value;
909
- try {
910
- const site = state.metadata.probeKey;
911
- const action = actionForProbe();
912
- if (action?.suppressed) return value;
913
- const weight = sampleWeight(state);
914
- if (weight === -2) {
915
- omitExecutionEvents(action);
916
- return value;
917
- }
918
- if (weight < 0) {
919
- if (weight === -1) increment(currentBranch, `${site}|S`, 1);
920
- omitExecutionEvents(action);
921
- }
922
- else if (weight > 0) {
923
- const outcome = value ? 'T' : 'F';
924
- increment(currentBranch, `${site}|${outcome}`, weight);
925
- if (action?.truncated) omitExecutionEvents(action);
926
- else if (action) addExecutionEvent(site, 'branch', 'branch-result', outcome);
927
- }
928
- } catch (error) {
929
- debug('branch probe failed open', error);
639
+ /** The window so far, taken atomically: each active site's deltas and its
640
+ * counters handed over and reset (pointer swaps). The handed-over counters
641
+ * stay accounted, as serializing, until each site's JSON replaces them. */
642
+ function takeWindow() {
643
+ const active = activeStates();
644
+ const taken = [];
645
+ for (let index = 0; index < active.length; index++) {
646
+ const state = active[index];
647
+ const entry = safeObjectCreate(null);
648
+ entry.state = state;
649
+ entry.calls = state.seen - state.flushedSeen;
650
+ entry.skipped = state.skipped;
651
+ entry.census = state.census;
652
+ entry.sampled = state.sampled;
653
+ entry.bytes = state.bytes;
654
+ safeArrayPush(taken, entry);
655
+ state.flushedSeen = state.seen;
656
+ state.skipped = 0;
657
+ state.census = null;
658
+ state.sampled = null;
659
+ state.bytes = 0;
930
660
  }
931
- return value;
661
+ memory.serializing += memory.counters;
662
+ memory.counters = 0;
663
+ return taken;
932
664
  }
933
665
 
934
- function branch(site, value) {
935
- const state = safeMapGet(probeStates, site);
936
- return state ? branchState(state, value) : value;
666
+ /** `{"<category>":<count>,...}` from a telemetry-owned Map of strings to integers. */
667
+ function countsJson(counters) {
668
+ if (counters === null) return '{}';
669
+ const parts = [];
670
+ const iterator = safeMapEntries(counters);
671
+ while (true) {
672
+ const step = safeMapIteratorNext(iterator);
673
+ if (step.done) break;
674
+ safeArrayPush(parts, `${quote(step.value[0])}:${step.value[1]}`);
675
+ }
676
+ return `{${safeArrayJoin(parts, ',')}}`;
937
677
  }
938
678
 
939
- function thrownState(state, value) {
940
- if (!enabled || !collecting || state.saturated) return value;
941
- try {
942
- const site = state.metadata.probeKey;
943
- const action = actionForProbe();
944
- if (action?.suppressed) return value;
945
- const weight = sampleWeight(state);
946
- if (weight === -2) {
947
- omitExecutionEvents(action);
948
- return value;
949
- }
950
- if (weight < 0) {
951
- if (weight === -1) increment(currentShape, `${site}|__saturated__|info`, 1);
952
- omitExecutionEvents(action);
953
- } else if (action?.truncated) omitExecutionEvents(action);
954
- else if (action) addExecutionEvent(site, 'error', 'thrown-value', `error:${errorClass(value)}`);
955
- } catch (error) {
956
- debug('throw probe failed open', error);
679
+ /** ServerSiteV5 from the site's null-prototype record of primitives. */
680
+ function siteJson(metadata) {
681
+ const fields = [];
682
+ for (let index = 0; index < metadata.fields.length; index++) safeArrayPush(fields, quote(metadata.fields[index]));
683
+ let json = `{"probeKey":${quote(metadata.probeKey)},"siteId":${quote(metadata.siteId)},` +
684
+ `"sourcePath":${quote(metadata.sourcePath)},"qualifiedName":${quote(metadata.qualifiedName)},` +
685
+ `"probeKind":${quote(metadata.probeKind)},"ownerKey":${quote(metadata.ownerKey)},` +
686
+ `"structuralKey":${quote(metadata.structuralKey)}`;
687
+ if (metadata.label !== null) json += `,"label":${quote(metadata.label)}`;
688
+ json += `,"allowedFields":[${safeArrayJoin(fields, ',')}]`;
689
+ if (metadata.line !== null) json += `,"line":${metadata.line}`;
690
+ if (metadata.column !== null) json += `,"column":${metadata.column}`;
691
+ if (metadata.settings !== null) {
692
+ const literals = [];
693
+ for (let index = 0; index < metadata.settings.length; index++) safeArrayPush(literals, literalJson(metadata.settings[index]));
694
+ json += `,"settings":[${safeArrayJoin(literals, ',')}]`;
957
695
  }
958
- return value;
696
+ return `${json}}`;
959
697
  }
960
698
 
961
- function thrown(site, value) {
962
- const state = safeMapGet(probeStates, site);
963
- return state ? thrownState(state, value) : value;
699
+ /** One site's part of a body, its bytes moved from serializing to queued. */
700
+ function unitOf(entry) {
701
+ const site = siteJson(entry.state.metadata);
702
+ const counts = `{"site":${quote(entry.state.metadata.siteId)},"calls":${entry.calls},` +
703
+ `"census":${countsJson(entry.census)},"sampled":${countsJson(entry.sampled)},` +
704
+ `"sampleRate":${SAMPLE_RATE},"skipped":${entry.skipped}}`;
705
+ const bytes = safeBufferByteLength(site, 'utf8') + safeBufferByteLength(counts, 'utf8') + 2;
706
+ memory.serializing -= entry.bytes;
707
+ entry.census = null;
708
+ entry.sampled = null;
709
+ memory.queued += bytes * 2;
710
+ if (used() > MAX_BYTES) throw new Error('memory cap');
711
+ return { site, counts, bytes };
964
712
  }
965
713
 
966
- function makePending(reason) {
967
- if (pending || pendingQueue.length > 0 || (safeMapSize(currentShape) === 0 &&
968
- safeMapSize(currentBranch) === 0 && safeMapSize(currentExecutions) === 0 &&
969
- omittedExecutionFamilyObservations === 0 && sampledActions === 0 && unsampledActions === 0 &&
970
- safeMapSize(currentOmissions) === 0)) return;
971
- const shape = objectFromMap(currentShape);
972
- const branchCounters = objectFromMap(currentBranch);
973
- currentShape = new SafeMap();
974
- currentBranch = new SafeMap();
975
- const executions = safeArraySort(
976
- mapValuesArray(currentExecutions),
977
- (left, right) => compareStrings(left.familyId, right.familyId),
978
- );
979
- currentExecutions = new SafeMap();
980
- currentExecutionBytes = 0;
981
- const executionOmissions = {
982
- sampledActions,
983
- unsampledActions,
984
- omittedFamilyObservations: omittedExecutionFamilyObservations,
985
- };
986
- sampledActions = 0;
987
- unsampledActions = 0;
988
- omittedExecutionFamilyObservations = 0;
989
- const omittedEntries = safeArraySort(
990
- mapEntriesArray(currentOmissions),
991
- (leftEntry, rightEntry) => compareStrings(leftEntry[0], rightEntry[0]),
992
- );
993
- const omittedSites = safeArrayMap(omittedEntries, entry => ({
994
- probeKey: entry[0],
995
- counterObservations: entry[1].counterObservations,
996
- probeObservations: entry[1].probeObservations,
997
- }));
998
- currentOmissions = new SafeMap();
999
- const counterGroups = new SafeMap();
1000
- const groupFor = (probeKey) => {
1001
- let group = safeMapGet(counterGroups, probeKey);
1002
- if (!group) {
1003
- group = { probeKey, shape: [], branch: [], omission: null };
1004
- safeMapSet(counterGroups, probeKey, group);
1005
- }
1006
- return group;
1007
- };
1008
- const shapeEntries = safeObjectEntries(shape);
1009
- for (let index = 0; index < shapeEntries.length; index++) {
1010
- const entry = shapeEntries[index];
1011
- safeArrayPush(groupFor(safeStringSlice(entry[0], 0, safeStringIndexOf(entry[0], '|'))).shape, entry);
1012
- }
1013
- const branchEntries = safeObjectEntries(branchCounters);
1014
- for (let index = 0; index < branchEntries.length; index++) {
1015
- const entry = branchEntries[index];
1016
- safeArrayPush(groupFor(safeStringSlice(entry[0], 0, safeStringIndexOf(entry[0], '|'))).branch, entry);
1017
- }
1018
- for (let index = 0; index < omittedSites.length; index++) {
1019
- const omission = omittedSites[index];
1020
- groupFor(omission.probeKey).omission = omission;
714
+ /** A ServerFlushV5 body, its sequence reserved now: every body built gets a
715
+ * sequence of its own, whether it is sent, retried, abandoned or spooled. */
716
+ function bodyOf(units, observedAt, reason) {
717
+ const sites = [];
718
+ const counts = [];
719
+ let bytes = 0;
720
+ for (let index = 0; index < units.length; index++) {
721
+ safeArrayPush(sites, units[index].site);
722
+ safeArrayPush(counts, units[index].counts);
723
+ bytes += units[index].bytes;
1021
724
  }
725
+ const seq = sequence++;
726
+ const body = `{"v":1,"samplerVersion":${CONTRACT.SERVER_SAMPLER_VERSION},"buildId":${quote(buildId)},` +
727
+ `"runId":${quote(runId)},"seq":${seq},"ts":${quote(observedAt)},` +
728
+ `"reason":${quote(safeStringSlice(safeString(reason), 0, 64))},` +
729
+ `"sites":[${safeArrayJoin(sites, ',')}],"counts":[${safeArrayJoin(counts, ',')}]}`;
730
+ // The units' strings are released once joined; the body's are now held.
731
+ const held = safeBufferByteLength(body, 'utf8');
732
+ memory.queued += held - bytes * 2;
733
+ return { seq, body, bytes: held };
734
+ }
1022
735
 
1023
- const packUnits = (units) => {
1024
- const chunks = [];
1025
- let current = [];
1026
- let bytes = 0;
1027
- for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
1028
- const unit = units[unitIndex];
1029
- let unitBytes = safeBufferByteLength(safeJsonStringify(unit), 'utf8') + 2_048;
1030
- if (unit.type === 'counter') {
1031
- unitBytes += safeBufferByteLength(safeJsonStringify(safeMapGet(sitesByProbe, unit.group.probeKey)), 'utf8');
1032
- } else if (unit.type === 'execution') {
1033
- unitBytes += safeArrayReduce(unit.unit.eventSites, (total, probeKey) =>
1034
- total + safeBufferByteLength(safeJsonStringify(safeMapGet(sitesByProbe, probeKey)), 'utf8'), 0);
1035
- }
1036
- if (current.length > 0 && bytes + unitBytes > PAYLOAD_CHUNK_BUDGET) {
1037
- safeArrayPush(chunks, current);
1038
- current = [];
1039
- bytes = 0;
1040
- }
1041
- safeArrayPush(current, unit);
1042
- bytes += unitBytes;
1043
- }
1044
- if (current.length > 0) safeArrayPush(chunks, current);
1045
- return chunks;
1046
- };
736
+ function release(entry) {
737
+ if (entry !== null) memory.queued -= entry.bytes;
738
+ }
1047
739
 
1048
- const executionUnits = safeArrayMap(executions, execution => {
1049
- const eventSiteSet = new SafeSet();
1050
- for (let eventIndex = 0; eventIndex < execution.events.length; eventIndex++) {
1051
- const event = execution.events[eventIndex];
1052
- const siteMetadata = safeMapGet(sitesById, event.siteId);
1053
- if (siteMetadata?.probeKey) safeSetAdd(eventSiteSet, siteMetadata.probeKey);
1054
- }
1055
- const eventSites = safeArraySort(setValuesArray(eventSiteSet), compareStrings);
1056
- return { execution, eventSites };
1057
- });
1058
- const counterUnits = safeArrayMap(
1059
- safeArraySort(mapValuesArray(counterGroups),
1060
- (left, right) => compareStrings(left.probeKey, right.probeKey)),
1061
- group => ({ type: 'counter', group }),
1062
- );
1063
- const projectedExecutionUnits = safeArrayMap(executionUnits, unit => ({ type: 'execution', unit }));
1064
- const unitsToPack = [];
1065
- for (let index = 0; index < counterUnits.length; index++) safeArrayPush(unitsToPack, counterUnits[index]);
1066
- for (let index = 0; index < projectedExecutionUnits.length; index++) {
1067
- safeArrayPush(unitsToPack, projectedExecutionUnits[index]);
1068
- }
1069
- const packed = packUnits(unitsToPack);
1070
- const chunkInputs = safeArrayMap(packed, units => ({
1071
- groups: safeArrayMap(safeArrayFilter(units, unit => unit.type === 'counter'), unit => unit.group),
1072
- executionUnits: safeArrayMap(safeArrayFilter(units, unit => unit.type === 'execution'), unit => unit.unit),
1073
- }));
1074
- if (chunkInputs.length === 0) safeArrayPush(chunkInputs, { groups: [], executionUnits: [] });
740
+ /** Bodies of the window so far, chunked under the upload limit. Interval
741
+ * flushes yield to the event loop between slices; the exit flush stops at
742
+ * its deadline (what it has not serialized is abandoned). */
743
+ async function makePending(reason) {
744
+ if (pending !== null || pendingQueue.length > 0) return;
745
+ const taken = takeWindow();
746
+ if (taken.length === 0) return;
1075
747
  const observedAt = safeDateToISOString(new SafeDate());
1076
- const serialized = safeArrayMap(chunkInputs, (chunk, index) => {
1077
- const chunkShape = objectFromEntryArray(safeArrayFlatMap(chunk.groups, group => group.shape));
1078
- const chunkBranch = objectFromEntryArray(safeArrayFlatMap(chunk.groups, group => group.branch));
1079
- const chunkOmittedSites = safeArrayFilter(
1080
- safeArrayMap(chunk.groups, group => group.omission),
1081
- item => item !== null && item !== undefined,
1082
- );
1083
- const chunkOmissions = safeArrayReduce(chunkOmittedSites, (totals, item) => ({
1084
- counterObservations: totals.counterObservations + item.counterObservations,
1085
- probeObservations: totals.probeObservations + item.probeObservations,
1086
- }), { counterObservations: 0, probeObservations: 0 });
1087
- const chunkExecutions = safeArrayMap(chunk.executionUnits, unit => unit.execution);
1088
- const usedProbeKeys = new SafeSet();
1089
- for (let groupIndex = 0; groupIndex < chunk.groups.length; groupIndex++) {
1090
- safeSetAdd(usedProbeKeys, chunk.groups[groupIndex].probeKey);
1091
- }
1092
- for (let unitIndex = 0; unitIndex < chunk.executionUnits.length; unitIndex++) {
1093
- const unit = chunk.executionUnits[unitIndex];
1094
- for (let siteIndex = 0; siteIndex < unit.eventSites.length; siteIndex++) {
1095
- safeSetAdd(usedProbeKeys, unit.eventSites[siteIndex]);
748
+ const bodies = [];
749
+ let units = [];
750
+ let unitsBytes = 0;
751
+ try {
752
+ for (let index = 0; index < taken.length; index++) {
753
+ if (exiting && safeDateNow() >= exitDeadline) break;
754
+ const unit = unitOf(taken[index]);
755
+ taken[index] = null;
756
+ if (units.length > 0 && unitsBytes + unit.bytes > PAYLOAD_CHUNK_BUDGET) {
757
+ safeArrayPush(bodies, bodyOf(units, observedAt, reason));
758
+ units = [];
759
+ unitsBytes = 0;
1096
760
  }
761
+ safeArrayPush(units, unit);
762
+ unitsBytes += unit.bytes;
763
+ if (index % FLUSH_SLICE_SITES === FLUSH_SLICE_SITES - 1) await nextTurn();
1097
764
  }
1098
- const payload = {
1099
- v: 1,
1100
- samplerVersion: SAMPLER_VERSION,
1101
- buildId,
1102
- runId,
1103
- seq: sequence + index,
1104
- ts: observedAt,
1105
- reason: safeStringSlice(safeString(reason), 0, 256),
1106
- omissions: chunkOmissions,
1107
- omittedSites: chunkOmittedSites,
1108
- executionOmissions: index === 0 ? executionOmissions : {
1109
- sampledActions: 0,
1110
- unsampledActions: 0,
1111
- omittedFamilyObservations: 0,
1112
- },
1113
- sites: safeArrayMap(safeArraySort(setValuesArray(usedProbeKeys), compareStrings),
1114
- key => safeMapGet(sitesByProbe, key)),
1115
- shape: chunkShape,
1116
- branch: chunkBranch,
1117
- executions: chunkExecutions,
1118
- coverage: [],
765
+ if (units.length > 0) safeArrayPush(bodies, bodyOf(units, observedAt, reason));
766
+ } finally {
767
+ // Whatever was taken and not serialized is released (abandoned at the deadline).
768
+ for (let index = 0; index < taken.length; index++) if (taken[index] !== null) memory.serializing -= taken[index].bytes;
769
+ }
770
+ pending = safeArrayShift(bodies) ?? null;
771
+ pendingQueue = bodies;
772
+ }
773
+
774
+ const target = new URL(endpoint);
775
+ const secure = target.protocol === 'https:';
776
+ const host = safeStringStartsWith(target.hostname, '[') ? safeStringSlice(target.hostname, 1, -1) : target.hostname;
777
+ const port = target.port ? safeNumberParseInt(target.port, 10) : secure ? 443 : 80;
778
+ const requestHead = `POST ${target.pathname}${target.search} HTTP/1.1\r\nHost: ${target.host}\r\n` +
779
+ `Authorization: Bearer ${token}\r\nContent-Type: application/json\r\nConnection: close\r\n`;
780
+
781
+ /** POST one body; resolves the response status (0: no response). Only the
782
+ * status line is read, then the socket is destroyed. */
783
+ function send(body, timeoutMs) {
784
+ return new SafePromise((resolve) => {
785
+ let settled = false;
786
+ let socket = null;
787
+ let timeout = null;
788
+ let head = '';
789
+ const finish = (status) => {
790
+ if (settled) return;
791
+ settled = true;
792
+ if (timeout !== null) safeClearTimeout(timeout);
793
+ if (currentSend === cancel) currentSend = null;
794
+ try {
795
+ if (socket !== null) socket.destroy();
796
+ } catch {
797
+ // Already gone.
798
+ }
799
+ resolve(status);
1119
800
  };
1120
- const body = safeJsonStringify(payload);
1121
- if (safeBufferByteLength(body, 'utf8') > MAX_PAYLOAD_BYTES) {
1122
- throw new SafeError('bounded telemetry unit exceeded the upload byte limit');
801
+ const cancel = () => finish(0);
802
+ try {
803
+ // Null-prototype options: net and tls read only their own members.
804
+ const options = safeObjectCreate(null);
805
+ options.host = host;
806
+ options.port = port;
807
+ if (secure && safeIsIP(host) === 0) options.servername = host;
808
+ socket = secure ? safeTlsConnect(options) : safeNetConnect(options);
809
+ socket.unref();
810
+ socket.on('error', () => finish(0));
811
+ socket.on('close', () => finish(0));
812
+ socket.on('data', (chunk) => {
813
+ head += safeBufferToString(chunk, 'latin1', 0, safeMathMin(chunk.length, 1024));
814
+ const lineEnd = safeStringIndexOf(head, '\r\n');
815
+ if (lineEnd < 0) {
816
+ if (head.length > 1024) finish(0);
817
+ return;
818
+ }
819
+ // `HTTP/1.1 202 Accepted`
820
+ const status = safeStringStartsWith(head, 'HTTP/1.') ? safeNumberParseInt(safeStringSlice(head, 9, 12), 10) : 0;
821
+ finish(status === status ? status : 0);
822
+ });
823
+ socket.once(secure ? 'secureConnect' : 'connect', () => {
824
+ socket.write(`${requestHead}Content-Length: ${safeBufferByteLength(body, 'utf8')}\r\n\r\n`);
825
+ socket.write(body);
826
+ });
827
+ timeout = safeSetTimeout(cancel, timeoutMs);
828
+ timeout.unref();
829
+ currentSend = cancel;
830
+ } catch {
831
+ finish(0);
1123
832
  }
1124
- return { payload, body };
1125
833
  });
1126
- pending = safeArrayShift(serialized) ?? null;
1127
- pendingQueue = serialized;
834
+ }
835
+
836
+ function remaining() {
837
+ return exiting ? exitDeadline - safeDateNow() : SEND_TIMEOUT_MS;
1128
838
  }
1129
839
 
1130
840
  async function doFlush(reason) {
1131
- if (!enabled) return false;
841
+ if (!counting) return false;
842
+ // A replay sends what an earlier process of this build left; never on the
843
+ // way out of this one.
844
+ if (!spoolReplayed && !exiting) {
845
+ spoolReplayed = true;
846
+ await replaySpooled();
847
+ if (!counting) return false;
848
+ }
1132
849
  try {
1133
- makePending(reason);
1134
- } catch (error) {
1135
- disable('payload construction failure', error);
850
+ await makePending(reason);
851
+ } catch {
852
+ turnOff('memory cap');
1136
853
  return false;
1137
854
  }
1138
- if (!pending) return true;
1139
- try {
1140
- let uploaded = false;
1141
- while (pending) {
1142
- const response = await safeFetch(endpoint, {
1143
- method: 'POST',
1144
- headers: {
1145
- Authorization: `Bearer ${token}`,
1146
- 'Content-Type': 'application/json',
1147
- },
1148
- body: pending.body,
1149
- signal: safeAbortTimeout(10_000),
1150
- });
1151
- if (!responseAccepted(response)) throw new SafeError(`ingestion returned HTTP ${responseStatus(response)}`);
1152
- pending = null;
1153
- sequence++;
1154
- uploaded = true;
1155
- pending = safeArrayShift(pendingQueue) ?? null;
855
+ if (pending === null) return true;
856
+ while (pending !== null) {
857
+ const budget = safeMathMin(SEND_TIMEOUT_MS, remaining());
858
+ if (budget <= 0) return false;
859
+ const status = await send(pending.body, budget);
860
+ if (!counting) return false;
861
+ if (status >= 200 && status < 300) {
862
+ release(pending);
863
+ pending = safeArrayShift(pendingQueue) ?? null;
864
+ continue;
1156
865
  }
1157
- consecutiveFailures = 0;
1158
- return true;
1159
- } catch (error) {
866
+ // The exact bodies stay pending for the next interval: the same
867
+ // (build, run, seq) key, so a retry the ingestion already stored is a
868
+ // duplicate it recognizes, never a double count.
869
+ if (exiting) return false;
1160
870
  consecutiveFailures++;
1161
- debug(`flush failed; exact payload retained (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`, error);
1162
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) disable('repeated flush failures', error);
871
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) turnOff(`${MAX_CONSECUTIVE_FAILURES} failed sends`);
1163
872
  return false;
1164
873
  }
874
+ consecutiveFailures = 0;
875
+ return true;
1165
876
  }
1166
877
 
1167
- const spoolDirectory = join(
1168
- tmpdir(),
1169
- `haystack-telemetry-${safeStringSlice(stableIdentity('owner', [endpoint, token, buildId]), 'owner-'.length)}`,
1170
- );
1171
-
1172
- function spoolBody(body) {
1173
- try {
1174
- const bodyBytes = safeBufferByteLength(body, 'utf8');
1175
- if (bodyBytes > MAX_PAYLOAD_BYTES) {
1176
- debug('oversized crash payload was not spooled');
1177
- return;
1178
- }
1179
- mkdirSync(spoolDirectory, { recursive: true, mode: 0o700 });
1180
- const digest = sha256Hex(body);
1181
- const finalPath = join(spoolDirectory, `pending-${digest}.json`);
1182
- const pendingNames = safeArrayFilter(readdirSync(spoolDirectory),
1183
- name => safeRegExpTest(/^pending-[0-9a-f]{64}\.json$/, name));
1184
- const described = safeArrayMap(pendingNames, name => {
1185
- const path = join(spoolDirectory, name);
1186
- try {
1187
- const stats = statSync(path);
1188
- return { name, path, bytes: stats.size, modified: stats.mtimeMs };
1189
- } catch {
1190
- return null;
1191
- }
1192
- });
1193
- const existing = safeArraySort(
1194
- safeArrayFilter(described, item => item !== null && item !== undefined),
1195
- (left, right) => left.modified - right.modified || compareStrings(left.name, right.name),
1196
- );
1197
- let retainedBytes = safeArrayReduce(existing, (total, file) => total + file.bytes, 0);
1198
- const receiptPath = join(spoolDirectory, 'dropped.json');
1199
- let dropped = { chunks: 0, bytes: 0, payload: null };
1200
- try {
1201
- const parsed = safeJsonParse(readFileSync(receiptPath, 'utf8'));
1202
- if (safeNumberIsSafeInteger(parsed.chunks) && parsed.chunks >= 0 &&
1203
- safeNumberIsSafeInteger(parsed.bytes) && parsed.bytes >= 0 &&
1204
- (parsed.payload === null || typeof parsed.payload === 'object')) dropped = parsed;
1205
- } catch {
1206
- // No prior eviction receipt is the expected case.
1207
- }
1208
- while (existing.length >= MAX_SPOOL_FILES || retainedBytes + bodyBytes > MAX_SPOOL_BYTES) {
1209
- const oldest = safeArrayShift(existing);
1210
- if (!oldest) break;
1211
- try {
1212
- unlinkSync(oldest.path);
1213
- retainedBytes -= oldest.bytes;
1214
- dropped.chunks++;
1215
- dropped.bytes += oldest.bytes;
1216
- dropped.payload = null;
1217
- } catch {
1218
- break;
1219
- }
1220
- }
1221
- if (retainedBytes + bodyBytes > MAX_SPOOL_BYTES) return;
1222
- if (dropped.chunks > 0) {
1223
- writeFileSync(receiptPath, safeJsonStringify(dropped), { encoding: 'utf8', mode: 0o600 });
1224
- }
1225
- const temporaryPath = join(spoolDirectory, `.pending-${process.pid}-${digest}.tmp`);
1226
- writeFileSync(temporaryPath, body, { encoding: 'utf8', mode: 0o600 });
1227
- renameSync(temporaryPath, finalPath);
1228
- } catch (error) {
1229
- debug('crash payload could not be spooled', error);
878
+ // Crash and exit spool: fixed slots in tmpdir (`pending-<0..7>.json`), at
879
+ // most SPOOL_FILE_BYTES each, written once per process with one synchronous
880
+ // exclusive-create write and sent by the next process of the same build and
881
+ // endpoint. The directory is created asynchronously at start; a read-only or
882
+ // full file system just means nothing is spooled.
883
+ const spoolDirectory = join(tmpdir(), `haystack-telemetry-${safeStringSlice(sha256Hex(`${endpoint}\0${token}\0${buildId}`), 0, 24)}`);
884
+ const spoolSlot = join(spoolDirectory, `pending-${hashSeed(runId) % MAX_SPOOL_FILES}.json`);
885
+ safePromiseThen(fsPromises.mkdir(spoolDirectory, { recursive: true, mode: 0o700 }), noop, noop);
886
+
887
+ /** The first queued body that fits a slot, or a fresh one-chunk body of the
888
+ * window bounded by the slot (serialization only; no I/O). */
889
+ function spoolCandidate(reason) {
890
+ if (pending !== null && pending.bytes <= SPOOL_FILE_BYTES) return pending.body;
891
+ for (let index = 0; index < pendingQueue.length; index++) {
892
+ if (pendingQueue[index].bytes <= SPOOL_FILE_BYTES) return pendingQueue[index].body;
1230
893
  }
1231
- }
1232
-
1233
- function spoolCurrent(reason) {
1234
- try {
1235
- if (pending) {
1236
- spoolBody(pending.body);
1237
- sequence = safeMathMax(sequence, pending.payload.seq + 1);
1238
- pending = null;
1239
- }
1240
- for (let index = 0; index < pendingQueue.length; index++) {
1241
- const queued = pendingQueue[index];
1242
- spoolBody(queued.body);
1243
- sequence = safeMathMax(sequence, queued.payload.seq + 1);
1244
- }
1245
- pendingQueue = [];
1246
- makePending(reason);
1247
- if (pending) {
1248
- spoolBody(pending.body);
1249
- for (let index = 0; index < pendingQueue.length; index++) spoolBody(pendingQueue[index].body);
1250
- }
1251
- } catch (error) {
1252
- debug('crash snapshot failed open', error);
894
+ const taken = takeWindow();
895
+ const units = [];
896
+ let unitsBytes = 0;
897
+ for (let index = 0; index < taken.length; index++) {
898
+ const unit = unitOf(taken[index]);
899
+ if (unitsBytes + unit.bytes > SPOOL_FILE_BYTES - 64 * 1024) break;
900
+ safeArrayPush(units, unit);
901
+ unitsBytes += unit.bytes;
1253
902
  }
903
+ return units.length > 0 ? bodyOf(units, safeDateToISOString(new SafeDate()), reason).body : null;
1254
904
  }
1255
905
 
1256
- async function replaySpooledPayloads() {
1257
- let files;
906
+ function spoolOnce(reason) {
907
+ if (spooled || !counting) return;
908
+ spooled = true;
1258
909
  try {
1259
- files = safeArraySlice(
1260
- safeArraySort(
1261
- safeArrayFilter(readdirSync(spoolDirectory),
1262
- name => safeRegExpTest(/^pending-[0-9a-f]{64}\.json$/, name)),
1263
- compareStrings,
1264
- ),
1265
- 0,
1266
- MAX_SPOOL_FILES,
1267
- );
910
+ const body = spoolCandidate(reason);
911
+ if (body !== null) writeFileSync(spoolSlot, body, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
1268
912
  } catch {
1269
- return;
913
+ // An occupied slot, a read-only or full file system: nothing is spooled.
1270
914
  }
1271
- const receiptPath = join(spoolDirectory, 'dropped.json');
1272
- try {
1273
- const dropped = safeJsonParse(readFileSync(receiptPath, 'utf8'));
1274
- if (safeNumberIsSafeInteger(dropped.chunks) && dropped.chunks > 0 &&
1275
- safeNumberIsSafeInteger(dropped.bytes) && dropped.bytes >= 0) {
1276
- const payload = dropped.payload && typeof dropped.payload === 'object'
1277
- ? dropped.payload
1278
- : {
1279
- v: 1,
1280
- samplerVersion: SAMPLER_VERSION,
1281
- buildId,
1282
- runId,
1283
- seq: sequence,
1284
- ts: safeDateToISOString(new SafeDate()),
1285
- reason: 'crash-spool-omission',
1286
- omissions: { counterObservations: 0, probeObservations: 0 },
1287
- omittedSites: [],
1288
- executionOmissions: {
1289
- sampledActions: 0,
1290
- unsampledActions: 0,
1291
- omittedFamilyObservations: 0,
1292
- },
1293
- deliveryOmissions: {
1294
- droppedCrashChunks: dropped.chunks,
1295
- droppedCrashBytes: dropped.bytes,
1296
- },
1297
- sites: [],
1298
- shape: {},
1299
- branch: {},
1300
- executions: [],
1301
- coverage: [],
1302
- };
1303
- // Reserve this identity before any asynchronous request. Normal and
1304
- // beforeExit flushes may run concurrently and must never reuse it.
1305
- if (payload.runId === runId && safeNumberIsSafeInteger(payload.seq)) {
1306
- sequence = safeMathMax(sequence, payload.seq + 1);
1307
- }
1308
- if (!dropped.payload) {
1309
- const persisted = { chunks: dropped.chunks, bytes: dropped.bytes, payload };
1310
- const temporaryPath = join(spoolDirectory, `.dropped-${process.pid}.tmp`);
1311
- writeFileSync(temporaryPath, safeJsonStringify(persisted), { encoding: 'utf8', mode: 0o600 });
1312
- renameSync(temporaryPath, receiptPath);
1313
- }
1314
- const response = await safeFetch(endpoint, {
1315
- method: 'POST',
1316
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
1317
- body: safeJsonStringify(payload),
1318
- signal: safeAbortTimeout(10_000),
1319
- });
1320
- if (!responseAccepted(response)) return;
1321
- // The receiver accepted this exact omission count. A retry would add
1322
- // the same loss again under a new process run, so acknowledge it now.
1323
- unlinkSync(receiptPath);
915
+ }
916
+
917
+ async function replaySpooled() {
918
+ for (let slot = 0; slot < MAX_SPOOL_FILES && counting && !exiting; slot++) {
919
+ const path = join(spoolDirectory, `pending-${slot}.json`);
920
+ let body;
921
+ try {
922
+ body = await fsPromises.readFile(path, 'utf8');
923
+ } catch {
924
+ continue;
1324
925
  }
1325
- } catch (error) {
1326
- if (error?.code !== 'ENOENT') debug('crash spool omission replay failed', error);
1327
- }
1328
- for (let index = 0; index < files.length; index++) {
1329
- const name = files[index];
1330
- const filePath = join(spoolDirectory, name);
1331
926
  try {
1332
- const body = readFileSync(filePath, 'utf8');
1333
- if (safeBufferByteLength(body, 'utf8') > MAX_PAYLOAD_BYTES) {
1334
- unlinkSync(filePath);
1335
- continue;
1336
- }
1337
- const response = await safeFetch(endpoint, {
1338
- method: 'POST',
1339
- headers: {
1340
- Authorization: `Bearer ${token}`,
1341
- 'Content-Type': 'application/json',
1342
- },
1343
- body,
1344
- signal: safeAbortTimeout(10_000),
1345
- });
1346
- if (!responseAccepted(response)) break;
1347
- unlinkSync(filePath);
1348
- } catch (error) {
1349
- debug('spooled crash payload replay failed', error);
1350
- break;
927
+ // A body cut short by a crash mid-write is not JSON: never sendable.
928
+ safeJsonParse(body);
929
+ } catch {
930
+ safePromiseThen(fsPromises.unlink(path), noop, noop);
931
+ continue;
932
+ }
933
+ const status = await send(body, SEND_TIMEOUT_MS);
934
+ // Accepted, or refused for good (a 4xx other than 408 timeout, 425 too
935
+ // early and 429 rate limit, which say "later"): the slot is free.
936
+ // Otherwise it waits for a later process.
937
+ const retryLater = status === 408 || status === 425 || status === 429;
938
+ if ((status >= 200 && status < 300) || (status >= 400 && status < 500 && !retryLater)) {
939
+ await safePromiseThen(fsPromises.unlink(path), noop, noop);
940
+ } else {
941
+ return;
1351
942
  }
1352
943
  }
1353
944
  }
1354
945
 
1355
946
  function flush(reason = 'manual') {
1356
947
  if (flushInFlight) return flushInFlight;
1357
- if (!processAction.suppressed && reason !== 'interval' && reason !== 'http-finish') {
1358
- finalizeAction(processAction, processAction.outcome || (process.exitCode ? 'error' : 'success'));
1359
- if (reason !== 'before-exit' && reason !== 'process-exit' &&
1360
- reason !== 'uncaught-exception' && reason !== 'unhandled-rejection') {
1361
- continueProcessAction();
1362
- }
1363
- }
1364
- flushInFlight = safePromiseFinally(doFlush(reason), () => {
948
+ flushInFlight = safePromiseFinally(safePromiseThen(doFlush(reason), result => result, () => false), () => {
1365
949
  flushInFlight = null;
1366
950
  });
1367
951
  return flushInFlight;
1368
952
  }
1369
953
 
1370
- const timer = setInterval(() => { void flush('interval'); }, flushIntervalMs);
954
+ const timer = safeSetInterval(() => {
955
+ void flush('interval');
956
+ }, flushIntervalMs);
1371
957
  timer.unref();
1372
- void replaySpooledPayloads();
1373
- process.on('uncaughtExceptionMonitor', (error) => {
1374
- const action = actionForProbe() || processAction;
1375
- if (action && !action.suppressed && !action.finalized) {
1376
- action.outcome = 'error';
1377
- action.terminalError = `error:${errorClass(error)}`;
1378
- finalizeAction(action, 'error');
1379
- }
1380
- spoolCurrent('uncaught-exception');
958
+ // The classification budget refills each second; a blocked event loop keeps
959
+ // it spent, which is exactly when classification should not add CPU.
960
+ const budgetTimer = safeSetInterval(() => {
961
+ budgetUsed = 0;
962
+ }, 1_000);
963
+ budgetTimer.unref();
964
+ processHost.on('uncaughtExceptionMonitor', () => {
965
+ spoolOnce('uncaught-exception');
1381
966
  });
1382
- process.once('exit', (code) => {
1383
- if (!processAction.suppressed && !processAction.finalized) {
1384
- processAction.terminalError = code ? 'error:generic' : processAction.terminalError;
1385
- finalizeAction(processAction, code ? 'error' : processAction.outcome || 'success');
1386
- }
1387
- if (pending || pendingQueue.length || safeMapSize(currentExecutions) ||
1388
- safeMapSize(currentShape) || safeMapSize(currentBranch)) {
1389
- spoolCurrent('process-exit');
1390
- }
967
+ processHost.once('exit', () => {
968
+ spoolOnce('process-exit');
1391
969
  });
1392
- process.once('beforeExit', () => {
1393
- if (beforeExitStarted) return;
1394
- beforeExitStarted = true;
1395
- if (!processAction.suppressed) {
1396
- finalizeAction(processAction, processAction.outcome || (process.exitCode ? 'error' : 'success'));
1397
- }
1398
- void flush('before-exit');
970
+ processHost.once('beforeExit', () => {
971
+ if (!counting || exiting) return;
972
+ // One absolute deadline for everything the way out does: a send in flight,
973
+ // serialization, and every send after it. At the deadline whatever is left
974
+ // is abandoned (and the exit handler may spool one bounded body).
975
+ exiting = true;
976
+ exitDeadline = safeDateNow() + EXIT_FLUSH_MS;
977
+ // Sends never keep the process alive; this one timer does, until the exit
978
+ // flush settles or the deadline, whichever is first.
979
+ const cut = safeSetTimeout(() => {
980
+ if (currentSend !== null) currentSend();
981
+ }, EXIT_FLUSH_MS);
982
+ const settled = flushInFlight ?? flush('before-exit');
983
+ safePromiseFinally(settled, () => safeClearTimeout(cut));
1399
984
  });
1400
985
 
1401
986
  const control = safeObjectFreeze({
1402
987
  enabled: true,
1403
988
  runtimeVersion: RUNTIME_VERSION,
989
+ samplerVersion: CONTRACT.SERVER_SAMPLER_VERSION,
1404
990
  buildId,
1405
991
  runId,
1406
992
  flush,
1407
- selectProbe(name) {
1408
- if (!enabled || processAction.suppressed) return passThrough;
1409
- if (name === '__hstValue') return observe;
1410
- if (name === '__hstBranch') return branch;
1411
- if (name === '__hstThrow') return thrown;
1412
- return null;
1413
- },
1414
993
  selectSiteProbe(name, site) {
1415
- if (!enabled || processAction.suppressed) return null;
1416
- const state = safeMapGet(probeStates, site);
1417
- if (!state) return null;
1418
- if (name === '__hstValue') {
1419
- const fields = state.metadata.allowedFields;
1420
- return (value) => {
1421
- if (!enabled || !collecting || state.saturated) return value;
1422
- return observeState(state, value, fields);
1423
- };
1424
- }
1425
- if (name === '__hstBranch') return (value) => {
1426
- if (!enabled || !collecting || state.saturated) return value;
1427
- return branchState(state, value);
1428
- };
1429
- if (name === '__hstThrow') return (value) => {
1430
- if (!enabled || !collecting || state.saturated) return value;
1431
- return thrownState(state, value);
1432
- };
1433
- return null;
1434
- },
1435
- snapshotCurrentAction() {
1436
- try {
1437
- const action = currentAction();
1438
- if (!action || action.actionKind !== 'http-server') return null;
1439
- return safeObjectFreeze({
1440
- actionKind: action.actionKind,
1441
- truncated: action.truncated,
1442
- omittedEvents: action.omittedEvents,
1443
- events: safeObjectFreeze(safeArrayMap(action.events, event => safeObjectFreeze({
1444
- siteId: event.siteId,
1445
- kind: event.kind,
1446
- scope: event.scope,
1447
- outcome: event.outcome,
1448
- observations: event.observations,
1449
- }))),
1450
- });
1451
- } catch (error) {
1452
- debug('current action snapshot failed open', error);
1453
- return null;
1454
- }
994
+ if (!counting) return null;
995
+ const state = safeMapGet(statesByProbe, site);
996
+ if (!state || state.probeName !== name) return null;
997
+ return siteProbe(state);
1455
998
  },
1456
999
  status() {
1457
1000
  return {
1458
- enabled,
1459
- collecting,
1001
+ counting,
1002
+ offReason,
1460
1003
  sequence,
1461
- processProbeObservations,
1462
- processProbeObservationLimit,
1004
+ activeSites: activeStates().length,
1005
+ memory: { ...memory, total: used(), cap: MAX_BYTES },
1006
+ consecutiveFailures,
1463
1007
  pending: pending !== null || pendingQueue.length > 0,
1464
- shapeKeys: safeMapSize(currentShape),
1465
- branchKeys: safeMapSize(currentBranch),
1466
- executionFamilies: safeMapSize(currentExecutions),
1467
1008
  };
1468
1009
  },
1469
1010
  });
@@ -1482,18 +1023,52 @@ function installRuntime(options = {}) {
1482
1023
  value: control,
1483
1024
  });
1484
1025
  }
1485
- debug(`enabled for build ${buildId}`);
1486
1026
  return control;
1487
1027
  }
1488
1028
 
1489
- function install(options = {}) {
1029
+ function selectProbe(_name, fallback) {
1030
+ // Sampler 5 binds per site (safeSelectSiteProbe); the shared probe is a
1031
+ // pass-through kept for generated fallback objects.
1032
+ return fallback;
1033
+ }
1034
+
1035
+ function trustedSelectSiteProbe(name, site, fallback) {
1036
+ try {
1037
+ const selected = activeControl?.selectSiteProbe(name, site);
1038
+ return typeof selected === 'function' ? selected : fallback;
1039
+ } catch {
1040
+ return fallback;
1041
+ }
1042
+ }
1043
+
1044
+ Object.defineProperty(trustedSelectSiteProbe, 'haystackRuntimeIntegrity', {
1045
+ configurable: false,
1046
+ enumerable: false,
1047
+ writable: false,
1048
+ value: RUNTIME_INTEGRITY,
1049
+ });
1050
+
1051
+ function safeSelectProbe(_expectedIntegrity, _name, fallback) {
1052
+ return fallback;
1053
+ }
1054
+
1055
+ function safeSelectSiteProbe(expectedIntegrity, name, site, fallback) {
1056
+ try {
1057
+ if (expectedIntegrity !== RUNTIME_INTEGRITY) return fallback;
1058
+ return trustedSelectSiteProbe(name, site, fallback);
1059
+ } catch {
1060
+ return fallback;
1061
+ }
1062
+ }
1063
+
1064
+ function install() {
1490
1065
  try {
1491
- return installRuntime(options);
1492
- } catch (error) {
1066
+ installResult = installRuntime();
1067
+ return installResult;
1068
+ } catch {
1493
1069
  // Bootstrap is injected into the application entry point, so this final
1494
1070
  // boundary must catch every setup failure. Telemetry is never allowed to
1495
1071
  // prevent the host application from starting.
1496
- debug('installation failed open', error);
1497
1072
  return { enabled: false, reason: 'installation-failed' };
1498
1073
  }
1499
1074
  }