node-avo-inspector 2.0.0-beta.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,46 +13,237 @@ exports.AvoInspector = void 0;
13
13
  const AvoInspectorEnv_1 = require("./AvoInspectorEnv");
14
14
  const AvoSchemaParser_1 = require("./AvoSchemaParser");
15
15
  const AvoNetworkCallsHandler_1 = require("./AvoNetworkCallsHandler");
16
+ const AvoBatchQueue_1 = require("./AvoBatchQueue");
16
17
  const AvoDeduplicator_1 = require("./AvoDeduplicator");
17
18
  const AvoStreamId_1 = require("./AvoStreamId");
18
19
  const AvoEventSpecFetcher_1 = require("./eventSpec/AvoEventSpecFetcher");
19
20
  const AvoEventSpecCache_1 = require("./eventSpec/AvoEventSpecCache");
20
21
  const EventValidator_1 = require("./eventSpec/EventValidator");
22
+ const AvoInspectorVersion_1 = require("./AvoInspectorVersion");
23
+ const AvoLog_1 = require("./AvoLog");
21
24
  const utils_1 = require("./utils");
22
- const libVersion = require("../package.json").version;
25
+ const libVersion = AvoInspectorVersion_1.VERSION;
26
+ const DEFAULT_BATCH_SIZE = 30;
27
+ const DEFAULT_BATCH_FLUSH_SECONDS = 30;
28
+ const DEFAULT_MAX_QUEUE_SIZE = 1000;
29
+ const DEFAULT_FLUSH_TIMEOUT_MS = 10000;
30
+ // The longest a track call can stay pending on its own work: a spec fetch (a socket wait
31
+ // and the fetch, 10 s each), then, in dev, its send (10 s). A send that also waits for a
32
+ // free slot can take longer; the call then resolves with its schema when the hold runs out.
33
+ const AWAITED_TRACK_HOLD_MS = 30000;
34
+ // A track call's promise that notices when someone awaits it. The SDK's sockets and
35
+ // timers do not hold the process, so a script awaiting a track that waits on a spec fetch
36
+ // or (in dev) on its send would otherwise look idle: "beforeExit" would fire mid-loop and
37
+ // the exit drain could end the process before the loop resumes. Once the promise is
38
+ // awaited (await, then, catch, finally, Promise.all all call then), a ref'd timer holds the
39
+ // process until it settles, at most AWAITED_TRACK_HOLD_MS; a fire-and-forget call with
40
+ // .catch() counts. A call whose promise nothing touches holds nothing. A call still pending
41
+ // when its hold runs out resolves with `onHoldExpired()`, so the script resumes; its event
42
+ // stays queued, and goes out with the exit drain or is reported as lost at exit.
43
+ class AwaitedTrackPromise extends Promise {
44
+ constructor() {
45
+ super(...arguments);
46
+ this.onFirstAwait = null;
47
+ }
48
+ static get [Symbol.species]() {
49
+ return Promise;
50
+ }
51
+ then(onFulfilled, onRejected) {
52
+ const onFirstAwait = this.onFirstAwait;
53
+ if (onFirstAwait !== null) {
54
+ this.onFirstAwait = null;
55
+ onFirstAwait();
56
+ }
57
+ return super.then(onFulfilled, onRejected);
58
+ }
59
+ }
60
+ const holdWhileAwaited = (work, onHoldExpired) => {
61
+ let settled = false;
62
+ let hold = null;
63
+ const done = () => {
64
+ settled = true;
65
+ if (hold !== null) {
66
+ clearTimeout(hold);
67
+ hold = null;
68
+ }
69
+ };
70
+ let release = () => { };
71
+ const tracked = new AwaitedTrackPromise((resolve, reject) => {
72
+ release = resolve;
73
+ work.then((value) => {
74
+ done();
75
+ resolve(value);
76
+ }, (reason) => {
77
+ done();
78
+ reject(reason);
79
+ });
80
+ });
81
+ tracked.onFirstAwait = () => {
82
+ if (!settled) {
83
+ hold = setTimeout(() => {
84
+ hold = null;
85
+ settled = true;
86
+ release(onHoldExpired());
87
+ }, AWAITED_TRACK_HOLD_MS);
88
+ }
89
+ };
90
+ return tracked;
91
+ };
92
+ // At an idle point with validations pending, how long the exit drain waits for them before
93
+ // sending anything (once per exit deadline).
94
+ const VALIDATION_GRACE_MS = 1000;
95
+ // Events waiting for a spec fetch at once, across every instance. The fetches share one
96
+ // 8-socket pool, so past this an event is sent without validation rather than queued.
97
+ const MAX_WAITING_VALIDATIONS = 1000;
98
+ // String() for a warning; a value with no string conversion (a null-prototype object)
99
+ // falls back to its tag, e.g. "[object Object]", which String() gives a plain object.
100
+ const valueToString = (value) => {
101
+ try {
102
+ return String(value);
103
+ }
104
+ catch (e) {
105
+ return Object.prototype.toString.call(value);
106
+ }
107
+ };
108
+ const NO_API_KEY_MESSAGE = "[Avo Inspector] No API key provided. Inspector can't operate without API key.";
109
+ // The keys of InspectorEvent; any other key in a tracked event is warned about.
110
+ const KNOWN_EVENT_KEYS = ["eventName", "eventProperties", "streamId", "outputReference", "originHint", "originAppVersion"];
23
111
  class AvoInspector {
24
- trackPending(promise) {
25
- this.pendingCount++;
26
- if (this.keepAliveTimer === null) {
27
- this.keepAliveTimer = setInterval(() => { }, 60000);
112
+ drainThisTurn() {
113
+ if (!this.drainScheduled) {
114
+ this.drainScheduled = true;
115
+ setImmediate(() => {
116
+ this.drainScheduled = false;
117
+ if (!this.destroyed) {
118
+ this.batchQueue.drain();
119
+ }
120
+ });
121
+ }
122
+ }
123
+ // Mirrors `promise`, but resolves `onDestroy` instead if destroy() runs first.
124
+ untilDestroyed(promise, onDestroy) {
125
+ return new Promise((resolve, reject) => {
126
+ const cancel = () => resolve(onDestroy);
127
+ this.destroyWaiters.add(cancel);
128
+ promise.then((value) => {
129
+ this.destroyWaiters.delete(cancel);
130
+ resolve(value);
131
+ }, (reason) => {
132
+ this.destroyWaiters.delete(cancel);
133
+ reject(reason);
134
+ });
135
+ });
136
+ }
137
+ // Mirrors a validation, but resolves null (send unvalidated) if giveUpValidations() runs first.
138
+ untilGivenUp(validation) {
139
+ return new Promise((resolve, reject) => {
140
+ const giveUp = () => resolve(null);
141
+ this.giveUpWaiters.add(giveUp);
142
+ validation.then((value) => {
143
+ this.giveUpWaiters.delete(giveUp);
144
+ resolve(value);
145
+ }, (reason) => {
146
+ this.giveUpWaiters.delete(giveUp);
147
+ reject(reason);
148
+ });
149
+ });
150
+ }
151
+ /** The exit drain ran out of time for validations: their events are sent unvalidated. */
152
+ giveUpValidations() {
153
+ const waiters = Array.from(this.giveUpWaiters);
154
+ this.giveUpWaiters.clear();
155
+ if (waiters.length > 0 && AvoInspector.shouldLog) {
156
+ console.log("Avo Inspector: exiting; sending " + waiters.length + " event(s) without waiting for their event spec.");
28
157
  }
158
+ waiters.forEach((giveUp) => giveUp());
159
+ }
160
+ get pendingCount() {
161
+ return this.pending.size;
162
+ }
163
+ trackPending(promise) {
164
+ this.pending.add(promise);
165
+ this.updateExitDrain();
29
166
  const done = () => {
30
- this.pendingCount--;
31
- if (this.pendingCount === 0 && this.keepAliveTimer !== null) {
32
- clearInterval(this.keepAliveTimer);
33
- this.keepAliveTimer = null;
34
- }
167
+ this.pending.delete(promise);
168
+ this.updateExitDrain();
35
169
  };
36
170
  promise.then(done, done);
171
+ return promise;
172
+ }
173
+ static armExitDrain() {
174
+ if (!AvoInspector.exitDrainArmed) {
175
+ process.once("beforeExit", AvoInspector.drainOnExit);
176
+ AvoInspector.exitDrainArmed = true;
177
+ }
178
+ }
179
+ static disarmExitDrain() {
180
+ if (AvoInspector.exitDrainArmed) {
181
+ process.removeListener("beforeExit", AvoInspector.drainOnExit);
182
+ AvoInspector.exitDrainArmed = false;
183
+ }
184
+ }
185
+ updateExitDrain() {
186
+ const hasWork = !this.destroyed && (this.batchQueue.length > 0 || this.pending.size > 0);
187
+ if (hasWork) {
188
+ AvoInspector.instancesWithWork.add(this);
189
+ AvoInspector.armExitDrain();
190
+ if (!AvoInspector.exitReportArmed) {
191
+ process.on("exit", AvoInspector.reportOnExit);
192
+ AvoInspector.exitReportArmed = true;
193
+ }
194
+ }
195
+ else {
196
+ AvoInspector.instancesWithWork.delete(this);
197
+ if (AvoInspector.instancesWithWork.size === 0) {
198
+ AvoInspector.disarmExitDrain();
199
+ process.removeListener("exit", AvoInspector.reportOnExit);
200
+ AvoInspector.exitReportArmed = false;
201
+ AvoInspector.exitDeadline = null;
202
+ }
203
+ }
37
204
  }
205
+ /** Whether the SDK writes its diagnostic log lines (shared by every instance). */
38
206
  static get shouldLog() {
39
207
  return this._shouldLog;
40
208
  }
41
209
  static set shouldLog(enable) {
42
210
  this._shouldLog = enable;
43
211
  }
212
+ /**
213
+ * Creates an Inspector instance. A missing or unsupported `env` falls back to dev.
214
+ *
215
+ * @throws if `apiKey` or `version` is blank, or `apiKey` contains a character that
216
+ * cannot be sent in an HTTP header.
217
+ */
44
218
  constructor(options) {
45
219
  this.eventSpecFetcher = null;
46
220
  this.eventSpecCache = null;
47
221
  this.eventValidator = null;
48
222
  this.generatedAnonymousId = "";
49
- // Keep the Node process alive while there are pending inspector operations.
50
- // Without this, short-lived processes (e.g. CLIs) can exit before async
51
- // sends complete, since the caller typically doesn't await the inspector promise.
52
- this.pendingCount = 0;
53
- this.keepAliveTimer = null;
223
+ this.destroyed = false;
224
+ // Settles each track still waiting on a spec fetch or an immediate send when destroy()
225
+ // runs. Entries remove themselves once their wait settles, so none outlive their call.
226
+ this.destroyWaiters = new Set();
227
+ // In-flight work (spec fetches before enqueue, and batch sends) that flush() awaits.
228
+ // Nothing here keeps the process alive: callers flush() or await before exit.
229
+ this.pending = new Set();
230
+ // Pending entries that are event spec validations, keyed by their pending promise.
231
+ this.validations = new Map();
232
+ // One drain for every flush-marked validation that settles in the same event-loop turn.
233
+ this.drainScheduled = false;
234
+ // Frees this instance's places among them; destroy() runs whatever is left.
235
+ this.releaseWaiting = new Set();
236
+ // Validations not finished yet (their events are not queued), settled when they are.
237
+ this.validationOutcomes = new Set();
238
+ // Gives up each validation still pending (giveUpValidations).
239
+ this.giveUpWaiters = new Set();
54
240
  // the constructor does aggressive null/undefined checking because same code paths will be accessible from JS
55
- if ((0, utils_1.isValueEmpty)(options.env)) {
241
+ if (options === null || typeof options !== "object") {
242
+ throw new Error(NO_API_KEY_MESSAGE);
243
+ }
244
+ // A non-string env is not "empty": it falls through to the unsupported-value check.
245
+ const env = options.env;
246
+ if (env === undefined || env === null || (typeof env === "string" && (0, utils_1.isValueEmpty)(env))) {
56
247
  this.environment = AvoInspectorEnv_1.AvoInspectorEnv.Dev;
57
248
  console.warn("[Avo Inspector] No environment provided. Defaulting to dev.");
58
249
  }
@@ -63,13 +254,23 @@ class AvoInspector {
63
254
  else {
64
255
  this.environment = options.env;
65
256
  }
66
- if ((0, utils_1.isValueEmpty)(options.apiKey)) {
67
- throw new Error("[Avo Inspector] No API key provided. Inspector can't operate without API key.");
257
+ if (typeof options.apiKey !== "string" || (0, utils_1.isValueEmpty)(options.apiKey)) {
258
+ throw new Error(NO_API_KEY_MESSAGE);
259
+ }
260
+ else if (/[\r\n\0]/.test(options.apiKey)) {
261
+ // The spec's exact message covers CR, LF and NUL.
262
+ throw new Error("[Avo Inspector] API key contains a control character. The API key is sent as a request header and cannot contain CR, LF, or NUL.");
263
+ }
264
+ else if ((0, utils_1.hasHeaderControlChar)(options.apiKey)) {
265
+ throw new Error("[Avo Inspector] apiKey must not contain control characters");
266
+ }
267
+ else if ((0, utils_1.hasNonLatin1Char)(options.apiKey)) {
268
+ throw new Error("[Avo Inspector] apiKey must only contain characters that can be sent in an HTTP header");
68
269
  }
69
270
  else {
70
271
  this.apiKey = options.apiKey;
71
272
  }
72
- if ((0, utils_1.isValueEmpty)(options.version)) {
273
+ if (typeof options.version !== "string" || (0, utils_1.isValueEmpty)(options.version)) {
73
274
  throw new Error("[Avo Inspector] No version provided. Many features of Inspector rely on versioning. Please provide comparable string version, i.e. integer or semantic.");
74
275
  }
75
276
  else {
@@ -94,28 +295,158 @@ class AvoInspector {
94
295
  }
95
296
  this.avoNetworkCallsHandler = new AvoNetworkCallsHandler_1.AvoNetworkCallsHandler(this.apiKey, this.environment.toString(), options.appName || "", this.version, libVersion, this.publicEncryptionKey);
96
297
  this.avoDeduplicator = new AvoDeduplicator_1.AvoDeduplicator();
298
+ const batchOptions = AvoInspector.resolveBatchOptions(options);
299
+ // dev sends every event immediately, whatever was configured.
300
+ this.batchSize =
301
+ this.environment === AvoInspectorEnv_1.AvoInspectorEnv.Dev ? 1 : batchOptions.batchSize;
302
+ // Not clamped: the conformance suite (batch-4) requires FIFO overflow in this case.
303
+ if (this.batchSize > batchOptions.maxQueueSize) {
304
+ console.warn("[Avo Inspector] batchSize " + this.batchSize + " is larger than maxQueueSize " +
305
+ batchOptions.maxQueueSize + ", so a batch never fills: events are sent only by the " +
306
+ "scheduled flush or flush(), and the oldest are dropped once " +
307
+ batchOptions.maxQueueSize + " are buffered. Set batchSize to at most maxQueueSize.");
308
+ }
309
+ this.batchQueue = new AvoBatchQueue_1.AvoBatchQueue(Object.assign(Object.assign({}, batchOptions), { batchSize: this.batchSize }), (batch) => this.sendBatch(batch), "failed",
310
+ // A batch is in flight from the moment it is swapped out, even while it waits for a
311
+ // send slot, so flush() waits for it.
312
+ (outcome) => this.trackPending(outcome));
97
313
  // Initialize event spec validation for dev/staging only
98
314
  if (this.environment !== AvoInspectorEnv_1.AvoInspectorEnv.Prod) {
99
- this.eventSpecFetcher = new AvoEventSpecFetcher_1.AvoEventSpecFetcher(this.apiKey);
315
+ // Spec fetches follow the track endpoint to a mock server, so validation runs there too.
316
+ this.eventSpecFetcher = new AvoEventSpecFetcher_1.AvoEventSpecFetcher(this.apiKey, this.avoNetworkCallsHandler.mockEndpoint);
100
317
  this.eventSpecCache = new AvoEventSpecCache_1.AvoEventSpecCache();
101
318
  this.eventValidator = new EventValidator_1.EventValidator();
102
319
  }
103
320
  }
104
- trackSchemaFromEvent(eventName, eventProperties, streamId) {
321
+ static resolveBatchOptions(options) {
322
+ const pick = (name, value, fallback, isValid) => {
323
+ if (value === undefined || value === null) {
324
+ return fallback;
325
+ }
326
+ if (typeof value === "number" && isValid(value)) {
327
+ return value;
328
+ }
329
+ console.warn(
330
+ // valueToString: concatenation throws for a symbol or a null-prototype object.
331
+ "[Avo Inspector] Invalid " + name + " " + valueToString(value) + ". Using default " + fallback + ".");
332
+ return fallback;
333
+ };
334
+ const isPositiveInteger = (v) => Number.isInteger(v) && v >= 1;
335
+ return {
336
+ batchSize: pick("batchSize", options.batchSize, DEFAULT_BATCH_SIZE, isPositiveInteger),
337
+ batchFlushSeconds: pick("batchFlushSeconds", options.batchFlushSeconds, DEFAULT_BATCH_FLUSH_SECONDS, (v) => Number.isFinite(v) && v > 0),
338
+ maxQueueSize: pick("maxQueueSize", options.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE, isPositiveInteger),
339
+ disableBatchTimer: options.disableBatchTimer === true,
340
+ };
341
+ }
342
+ /**
343
+ * Resolves the gateway options for one event. outputReference / originHint are
344
+ * omitted when blank; appVersion is the per-event override, null for a
345
+ * source-scoped event without one, else the instance version.
346
+ */
347
+ resolveGatewayFields(options) {
348
+ const opts = options !== null && typeof options === "object" ? options : {};
349
+ const outputReference = (0, utils_1.normalizeOption)(opts.outputReference);
350
+ const originHint = (0, utils_1.normalizeOption)(opts.originHint);
351
+ const originAppVersion = (0, utils_1.normalizeOption)(opts.originAppVersion);
352
+ const resolved = {
353
+ gatewayScoped: outputReference !== undefined ||
354
+ originHint !== undefined ||
355
+ originAppVersion !== undefined,
356
+ appVersion: originAppVersion !== undefined
357
+ ? originAppVersion
358
+ : originHint !== undefined
359
+ ? null
360
+ : this.version,
361
+ };
362
+ if (outputReference !== undefined) {
363
+ resolved.outputReference = outputReference;
364
+ }
365
+ if (originHint !== undefined) {
366
+ resolved.originHint = originHint;
367
+ }
368
+ return resolved;
369
+ }
370
+ // Warns (always on, rate-limited) when the event has keys other than InspectorEvent's,
371
+ // such as a typo; the known keys are still used and the event is still tracked.
372
+ // Never throws: listing a proxy's keys can.
373
+ static warnUnknownEventKeys(event) {
374
+ let unknown;
105
375
  try {
376
+ unknown = Object.keys(event).filter((key) => KNOWN_EVENT_KEYS.indexOf(key) < 0);
377
+ }
378
+ catch (e) {
379
+ return;
380
+ }
381
+ if (unknown.length > 0) {
382
+ AvoLog_1.AvoLog.unknownEventKeys(unknown, KNOWN_EVENT_KEYS);
383
+ }
384
+ }
385
+ /**
386
+ * Extracts the event schema and queues it for the Inspector API.
387
+ *
388
+ * Resolves with the schema once the event is queued. In dev (batch size 1) the send
389
+ * happens within the call and a non-200 response resolves `[]`; with batching the
390
+ * batch's HTTP outcome is not observable here. Buffered events are only delivered
391
+ * by a later size or time trigger, or by `flush()` — call `flush()` before exit.
392
+ *
393
+ * Anything but an object (such as a 1.x call `trackSchemaFromEvent(name, properties)`)
394
+ * sends nothing, prints an always-on error naming the fix, and resolves `[]`.
395
+ */
396
+ trackSchemaFromEvent(event) {
397
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
398
+ AvoLog_1.AvoLog.notAnInspectorEvent();
399
+ return Promise.resolve([]);
400
+ }
401
+ AvoInspector.warnUnknownEventKeys(event);
402
+ const call = { schema: [] };
403
+ let work;
404
+ try {
405
+ // Each field is read once, here: a getter on the event runs only once.
406
+ const { eventName, eventProperties, streamId, outputReference, originHint, originAppVersion } = event;
407
+ work = this.track(eventName,
408
+ // Absent or null: no properties. Every step below handles both, as for a JS caller.
409
+ eventProperties, false, null, null, streamId, { outputReference, originHint, originAppVersion }, call);
410
+ }
411
+ catch (e) {
412
+ AvoLog_1.AvoLog.internal(e);
413
+ work = Promise.reject(AvoLog_1.INTERNAL_ERROR_MESSAGE);
414
+ }
415
+ return holdWhileAwaited(work, () => call.schema);
416
+ }
417
+ /**
418
+ * Codegen entry point: like `trackSchemaFromEvent`, and additionally records the
419
+ * Avo function's `eventId` / `eventHash` so the event is sent with `avoFunction: true`.
420
+ */
421
+ _avoFunctionTrackSchemaFromEvent(eventName, eventProperties, eventId, eventHash, streamId, options) {
422
+ const call = { schema: [] };
423
+ return holdWhileAwaited(this.track(eventName, eventProperties, true, eventId, eventHash, streamId, options, call), () => call.schema);
424
+ }
425
+ // Shared by the manual and Codegen entry points.
426
+ track(eventName, eventProperties, fromAvoFunction, eventId, eventHash, streamId, options, call) {
427
+ AvoInspector.lastTrackAt = (0, utils_1.monotonicNowMs)();
428
+ try {
429
+ if (this.destroyed) {
430
+ return Promise.resolve([]);
431
+ }
432
+ // A missing event name (null, undefined, non-string, empty or whitespace-only) is
433
+ // still reported to Inspector, under a placeholder name. A valid name is kept as is.
434
+ if (typeof eventName !== "string" || eventName.trim().length === 0) {
435
+ AvoLog_1.AvoLog.missingEventName();
436
+ eventName = AvoLog_1.MISSING_EVENT_NAME;
437
+ }
106
438
  const avoStreamId = new AvoStreamId_1.AvoStreamId(streamId);
107
439
  const anonymousId = avoStreamId.streamId || this.generatedAnonymousId;
108
- if (this.avoDeduplicator.shouldRegisterEvent(eventName, eventProperties, false, anonymousId)) {
440
+ const gatewayFields = this.resolveGatewayFields(options);
441
+ if (this.shouldRegisterEvent(eventName, eventProperties, fromAvoFunction, anonymousId, gatewayFields)) {
442
+ let eventSchema = this.extractSchema(eventProperties, false);
443
+ call.schema = eventSchema;
109
444
  if (AvoInspector.shouldLog) {
110
- console.log("Avo Inspector: Supplied event " +
111
- eventName +
112
- " with params \n" +
113
- JSON.stringify(eventProperties));
445
+ // The schema (names and types), never the values: the logging flag is shared by
446
+ // every instance, so a prod instance can log once any dev instance turns it on.
447
+ console.log("Avo Inspector: Supplied event " + eventName + " with schema " + JSON.stringify(eventSchema));
114
448
  }
115
- let eventSchema = this.extractSchema(eventProperties, false);
116
- return this.sendEventWithOptionalValidation(eventName, eventSchema, null, null, anonymousId, eventProperties).then(() => {
117
- return eventSchema;
118
- });
449
+ return this.sampleAndEnqueue(eventName, eventSchema, eventId, eventHash, anonymousId, eventProperties, gatewayFields);
119
450
  }
120
451
  else {
121
452
  if (AvoInspector.shouldLog) {
@@ -125,102 +456,266 @@ class AvoInspector {
125
456
  }
126
457
  }
127
458
  catch (e) {
128
- console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e);
129
- return Promise.reject("Avo Inspector: something went wrong. Please report to support@avo.app.");
459
+ AvoLog_1.AvoLog.internal(e);
460
+ return Promise.reject(AvoLog_1.INTERNAL_ERROR_MESSAGE);
130
461
  }
131
462
  }
132
- _avoFunctionTrackSchemaFromEvent(eventName, eventProperties, eventId, eventHash, streamId) {
133
- try {
134
- const anonymousId = (streamId && streamId.length > 0) ? streamId : this.generatedAnonymousId;
135
- if (this.avoDeduplicator.shouldRegisterEvent(eventName, eventProperties, true, anonymousId)) {
463
+ /**
464
+ * Codegen/manual deduplication applies only to plain calls. A call carrying gateway
465
+ * options is a distinct observation per gateway output and is always sent.
466
+ */
467
+ shouldRegisterEvent(eventName, eventProperties, fromAvoFunction, anonymousId, gatewayFields) {
468
+ if (gatewayFields.gatewayScoped) {
469
+ return true;
470
+ }
471
+ return this.avoDeduplicator.shouldRegisterEvent(eventName, eventProperties, fromAvoFunction, anonymousId);
472
+ }
473
+ /**
474
+ * Samples the event, then queues it. When event spec validation is active the spec
475
+ * is fetched (or read from cache) first, so the event joins the queue once its
476
+ * validation results are merged in; flush() awaits that fetch before draining.
477
+ */
478
+ sampleAndEnqueue(eventName, eventSchema, eventId, eventHash, anonymousId, rawEventProperties, gatewayFields) {
479
+ const samplingRate = this.avoNetworkCallsHandler.getSamplingRate();
480
+ // Stamped at the call: with validation active the event may join the queue later.
481
+ const createdAt = new Date().toISOString();
482
+ if (Math.random() > samplingRate) {
483
+ if (AvoInspector.shouldLog) {
484
+ console.log("Avo Inspector: event " + eventName + " dropped due to sampling rate.");
485
+ }
486
+ return Promise.resolve(eventSchema);
487
+ }
488
+ // The rate that governed this event's sampling decision, not the one at send time.
489
+ const stamp = { createdAt, samplingRate };
490
+ const buildBody = (validationResult) => {
491
+ // The body gets its own copy: the call resolves with eventSchema, which the caller
492
+ // may change while the event still waits in the batch.
493
+ const bodySchema = JSON.parse(JSON.stringify(eventSchema));
494
+ let body;
495
+ if (validationResult) {
136
496
  if (AvoInspector.shouldLog) {
137
- console.log("Avo Inspector: Supplied event " +
138
- eventName +
139
- " with params \n" +
140
- JSON.stringify(eventProperties));
497
+ console.log("Avo Inspector: Sending validated event " + eventName);
141
498
  }
142
- let eventSchema = this.extractSchema(eventProperties, false);
143
- return this.sendEventWithOptionalValidation(eventName, eventSchema, eventId, eventHash, anonymousId, eventProperties).then(() => {
144
- return eventSchema;
145
- });
499
+ const eventProps = this.avoNetworkCallsHandler.buildEventProperties(bodySchema, rawEventProperties);
500
+ body = this.avoNetworkCallsHandler.bodyForValidatedEventSchemaCall(anonymousId, eventName, eventProps, eventId, eventHash, validationResult.metadata, validationResult.propertyResults, gatewayFields, stamp);
146
501
  }
147
502
  else {
148
- if (AvoInspector.shouldLog) {
149
- console.log("Avo Inspector: Deduplicated event " + eventName);
503
+ body = this.avoNetworkCallsHandler.bodyForEventSchemaCall(anonymousId, eventName, bodySchema, eventId, eventHash, rawEventProperties, gatewayFields, stamp);
504
+ }
505
+ return body;
506
+ };
507
+ if (!this.isValidationActive()) {
508
+ return this.enqueue(buildBody(null), eventSchema).result;
509
+ }
510
+ // A flush() that starts while this validation is in progress sets flushRequested:
511
+ // the event then goes out once it is queued, in the drain shared by every validation
512
+ // that settles in the same event-loop turn, and flush() waits for that send.
513
+ const validation = { flushRequested: false };
514
+ const outcome = this.untilDestroyed(this.untilGivenUp(this.fetchAndValidate(eventName, eventSchema, anonymousId, rawEventProperties, gatewayFields)), null)
515
+ .catch((err) => {
516
+ if (AvoInspector.shouldLog) {
517
+ // Only the error's type: validation reads property values, which can end up in it.
518
+ console.warn("Avo Inspector: Event spec validation failed for event: " + eventName + ". Sending without validation. (" + AvoLog_1.AvoLog.errorType(err) + ")");
519
+ }
520
+ return null;
521
+ })
522
+ .then((validationResult) => {
523
+ if (this.destroyed) {
524
+ return { result: Promise.resolve([]), send: null };
525
+ }
526
+ try {
527
+ const enqueued = this.enqueue(buildBody(validationResult), eventSchema);
528
+ let send = enqueued.send;
529
+ if (send === null && validation.flushRequested) {
530
+ // flush() waits for the batch that carries this event. Another drain (a size
531
+ // trigger or the timer) may swap it out before the scheduled one runs, so the
532
+ // wait follows the buffered batch, not this particular drain.
533
+ send = this.batchQueue.bufferedBatchOutcome();
534
+ this.drainThisTurn();
150
535
  }
151
- return Promise.resolve([]);
536
+ return { result: enqueued.result, send };
152
537
  }
538
+ catch (err) {
539
+ // Same outcome as a synchronous internal error on the unvalidated path (SPEC §4.2
540
+ // step 5): logged, and the call rejects with the internal error message.
541
+ AvoLog_1.AvoLog.internal(err);
542
+ return { result: null, send: null };
543
+ }
544
+ });
545
+ this.validationOutcomes.add(outcome);
546
+ const validated = () => {
547
+ this.validationOutcomes.delete(outcome);
548
+ };
549
+ outcome.then(validated, validated);
550
+ // In flight until the event is queued and, if that triggered a send, until it settles.
551
+ const inFlight = outcome.then(({ send }) => (send ? send.then(() => undefined) : undefined));
552
+ this.validations.set(inFlight, validation);
553
+ const forget = () => {
554
+ this.validations.delete(inFlight);
555
+ };
556
+ inFlight.then(forget, forget);
557
+ this.trackPending(inFlight);
558
+ return outcome.then(({ result }) => result || Promise.reject(AvoLog_1.INTERNAL_ERROR_MESSAGE));
559
+ }
560
+ enqueue(body, eventSchema) {
561
+ const send = this.batchQueue.enqueue(body);
562
+ this.updateExitDrain();
563
+ if (this.batchSize === 1 && send !== null) {
564
+ // Immediate send: the HTTP outcome is observable per call. An event whose send is
565
+ // abandoned by destroy() is not delivered, so it resolves [] like a non-200.
566
+ return {
567
+ result: this.untilDestroyed(send.then((outcome) => (outcome === "non200" || this.destroyed ? [] : eventSchema)), []),
568
+ send,
569
+ };
153
570
  }
154
- catch (e) {
155
- console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e);
156
- return Promise.reject("Avo Inspector: something went wrong. Please report to support@avo.app.");
571
+ if (this.batchQueue.waitingLength >= AvoBatchQueue_1.BACKPRESSURE_WAITING_EVENTS) {
572
+ // Backpressure: the event is queued, but the call resolves only once a send slot
573
+ // frees up enough room, so an awaited loop cannot outrun the sends and overflow the
574
+ // backlog. A call that is not awaited is unaffected. destroy() resolves it [].
575
+ return {
576
+ result: this.untilDestroyed(this.batchQueue.whenBelowBackpressure().then(() => (this.destroyed ? [] : eventSchema)), []),
577
+ send,
578
+ };
157
579
  }
580
+ return { result: Promise.resolve(eventSchema), send };
581
+ }
582
+ sendBatch(batch) {
583
+ if (this.destroyed) {
584
+ return Promise.resolve("failed");
585
+ }
586
+ const send = this.avoNetworkCallsHandler.callInspectorWithBatchBody(batch).then((status) => {
587
+ if (typeof status === "number" && status !== 200) {
588
+ // Not logged for a send abandoned by destroy().
589
+ if (!this.destroyed) {
590
+ AvoLog_1.AvoLog.rejected(status);
591
+ }
592
+ return "non200";
593
+ }
594
+ if (AvoInspector.shouldLog) {
595
+ batch.forEach((event) => {
596
+ console.log("Avo Inspector: Saved event " + event.eventName + " with schema " + (0, utils_1.formatSchema)(event.eventProperties));
597
+ });
598
+ }
599
+ return "ok";
600
+ }, (err) => {
601
+ // At-most-once: a failed batch is dropped, never re-queued or retried.
602
+ if (!this.destroyed) {
603
+ AvoLog_1.AvoLog.failed(err);
604
+ }
605
+ return "failed";
606
+ });
607
+ return send;
608
+ }
609
+ isValidationActive() {
610
+ return this.eventSpecFetcher !== null;
158
611
  }
159
612
  /**
160
- * Try to fetch event spec and validate, then send a single event call.
161
- * If validation succeeds, sends a validated event (with results merged in).
162
- * If unavailable, sends a plain event.
163
- *
164
- * Uses trackPending() to keep the Node process alive until the work completes,
165
- * so short-lived processes (CLIs) don't exit before the send finishes — even
166
- * when the caller doesn't await the returned promise.
613
+ * Sends every buffered event, then waits until all in-flight sends (and spec fetches
614
+ * that will enqueue) have completed or `timeoutMs` (default 10000) has elapsed.
615
+ * Resolves `true` if, when it resolves, this instance has nothing buffered, waiting or in
616
+ * flight (always after destroy()), and `false` if the timeout won. Never rejects. Call it
617
+ * before process exit or before a serverless handler returns.
167
618
  */
168
- sendEventWithOptionalValidation(eventName, eventSchema, eventId, eventHash, anonymousId, rawEventProperties) {
169
- const work = this.doSendEventWithOptionalValidation(eventName, eventSchema, eventId, eventHash, anonymousId, rawEventProperties);
170
- this.trackPending(work);
171
- return work;
619
+ flush() {
620
+ return __awaiter(this, arguments, void 0, function* (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) {
621
+ const budget = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0
622
+ ? Math.min(timeoutMs, AvoBatchQueue_1.MAX_TIMER_MS)
623
+ : DEFAULT_FLUSH_TIMEOUT_MS;
624
+ // While an explicit flush runs, the exit drain holds the process until its deadline too.
625
+ const token = {};
626
+ AvoInspector.explicitFlushDeadlines.set(token, (0, utils_1.monotonicNowMs)() + budget);
627
+ try {
628
+ yield this.flushWithin(budget);
629
+ }
630
+ finally {
631
+ AvoInspector.explicitFlushDeadlines.delete(token);
632
+ }
633
+ return this.hasDrained();
634
+ });
172
635
  }
173
- doSendEventWithOptionalValidation(eventName, eventSchema, eventId, eventHash, anonymousId, rawEventProperties) {
636
+ // Nothing buffered, waiting for a send slot, or in flight (spec validations included).
637
+ hasDrained() {
638
+ return (this.destroyed ||
639
+ (this.pending.size === 0 && this.batchQueue.length === 0 && this.batchQueue.waitingLength === 0));
640
+ }
641
+ // The body of flush(): also used by the exit drain, whose own deadline is not an explicit one.
642
+ // Resolves whether everything it waited for settled within the budget (false if the
643
+ // budget ran out first).
644
+ flushWithin(budget) {
174
645
  return __awaiter(this, void 0, void 0, function* () {
175
- const validationResult = yield this.fetchAndValidate(eventName, eventSchema, anonymousId, rawEventProperties, eventId);
176
646
  try {
177
- let body;
178
- if (validationResult) {
179
- if (AvoInspector.shouldLog) {
180
- console.log("Avo Inspector: Sending validated event " + eventName);
181
- }
182
- const eventProps = this.avoNetworkCallsHandler.buildEventProperties(eventSchema, rawEventProperties);
183
- body = this.avoNetworkCallsHandler.bodyForValidatedEventSchemaCall(anonymousId, eventName, eventProps, eventId, eventHash, validationResult.metadata, validationResult.propertyResults);
647
+ if (this.destroyed) {
648
+ return true;
184
649
  }
185
- else {
186
- body = this.avoNetworkCallsHandler.bodyForEventSchemaCall(anonymousId, eventName, eventSchema, eventId, eventHash, rawEventProperties);
650
+ // Only work started before this call, plus the batch it drains, is awaited.
651
+ const waitFor = Array.from(this.pending);
652
+ waitFor.forEach((promise) => {
653
+ const validation = this.validations.get(promise);
654
+ if (validation) {
655
+ validation.flushRequested = true;
656
+ }
657
+ });
658
+ const drained = this.batchQueue.drain();
659
+ if (drained !== null) {
660
+ waitFor.push(drained);
187
661
  }
188
- yield this.avoNetworkCallsHandler.callInspectorWithBatchBody([body]);
189
- if (AvoInspector.shouldLog) {
190
- const schemaString = eventSchema.map(p => '\t"' + p.propertyName + '": "' + p.propertyType + '"').join(";\n");
191
- console.log("Avo Inspector: Saved event " + eventName + " with schema {\n" + schemaString + "\n}");
662
+ if (waitFor.length === 0) {
663
+ return true;
192
664
  }
665
+ let timer;
666
+ const completed = yield Promise.race([
667
+ Promise.allSettled(waitFor).then(() => true),
668
+ new Promise((resolve) => {
669
+ timer = setTimeout(() => resolve(false), budget);
670
+ timer.unref();
671
+ }),
672
+ ]);
673
+ clearTimeout(timer);
674
+ return completed;
193
675
  }
194
- catch (err) {
195
- console.error("Avo Inspector: schema sending failed: " + err + ".");
676
+ catch (e) {
677
+ // flush() is a completion guarantee and never rejects.
678
+ return false;
679
+ }
680
+ finally {
681
+ // Counts whose window has expired are printed now; a count still inside its window
682
+ // stays pending, so an app that flushes after every event keeps the 10 s limit.
683
+ AvoLog_1.AvoLog.flushPending(true);
196
684
  }
197
685
  });
198
686
  }
687
+ /** @internal Test-only hook for the conformance harness; not part of the public API. */
688
+ _setSamplingRateForTesting(samplingRate) {
689
+ this.avoNetworkCallsHandler._setSamplingRateForTesting(samplingRate);
690
+ }
691
+ /** Turns the SDK's diagnostic logging on or off for every instance. */
199
692
  enableLogging(enable) {
200
693
  AvoInspector._shouldLog = enable;
201
694
  }
695
+ /** Returns the schema of `eventProperties` without tracking or sending anything. */
202
696
  extractSchema(eventProperties, shouldLogIfEnabled = true) {
203
697
  try {
204
- if (this.avoDeduplicator.hasSeenEventParams(eventProperties, true)) {
205
- if (shouldLogIfEnabled && AvoInspector.shouldLog) {
206
- console.warn("Avo Inspector: WARNING! You are trying to extract schema shape that was just reported by your Codegen. " +
207
- "This is an indicator of duplicate inspector reporting. " +
208
- "Please reach out to support@avo.app for advice if you are not sure how to handle this.");
209
- }
698
+ // The scan compares against every recent Codegen entry, so it runs only when its
699
+ // warning can print (never on the tracking path, which passes false).
700
+ if (shouldLogIfEnabled &&
701
+ AvoInspector.shouldLog &&
702
+ this.avoDeduplicator.hasSeenEventParams(eventProperties, true)) {
703
+ console.warn("Avo Inspector: WARNING! You are trying to extract schema shape that was just reported by your Codegen. " +
704
+ "This is an indicator of duplicate inspector reporting. " +
705
+ "Please reach out to support@avo.app for advice if you are not sure how to handle this.");
210
706
  }
211
707
  if (AvoInspector.shouldLog) {
212
- console.log("Avo Inspector: extracting schema from " +
213
- JSON.stringify(eventProperties));
708
+ // No property values in logs; the parsed schema is logged below.
709
+ console.log("Avo Inspector: extracting schema");
214
710
  }
215
711
  const schema = AvoSchemaParser_1.AvoSchemaParser.extractSchema(eventProperties);
216
712
  if (AvoInspector.shouldLog) {
217
- const schemaString = schema.map(p => '\t"' + p.propertyName + '": "' + p.propertyType + '"').join(";\n");
218
- console.log("Avo Inspector: Parsed schema {\n" + schemaString + "\n}");
713
+ console.log("Avo Inspector: Parsed schema " + (0, utils_1.formatSchema)(schema));
219
714
  }
220
715
  return schema;
221
716
  }
222
717
  catch (e) {
223
- console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e);
718
+ AvoLog_1.AvoLog.internal(e);
224
719
  return [];
225
720
  }
226
721
  }
@@ -228,7 +723,7 @@ class AvoInspector {
228
723
  * Fetch event spec and validate. Returns validation results + metadata if
229
724
  * validation succeeds, or null if unavailable (prod, no spec, fetch error).
230
725
  */
231
- fetchAndValidate(eventName, eventSchema, anonymousId, rawEventProperties, eventId) {
726
+ fetchAndValidate(eventName, eventSchema, anonymousId, rawEventProperties, gatewayFields) {
232
727
  return __awaiter(this, void 0, void 0, function* () {
233
728
  if (!this.eventSpecFetcher || !this.eventSpecCache || !this.eventValidator) {
234
729
  if (AvoInspector.shouldLog) {
@@ -239,7 +734,16 @@ class AvoInspector {
239
734
  }
240
735
  return null;
241
736
  }
242
- const cacheKey = AvoEventSpecCache_1.AvoEventSpecCache.makeKey(this.apiKey, anonymousId, eventName);
737
+ // The same normalized coordinates as on the track body: a gateway key answers the spec
738
+ // of that checkpoint, and each checkpoint's spec has its own cache entry and fetch.
739
+ const coordinates = {};
740
+ if (gatewayFields && gatewayFields.originHint !== undefined) {
741
+ coordinates.originHint = gatewayFields.originHint;
742
+ }
743
+ if (gatewayFields && gatewayFields.outputReference !== undefined) {
744
+ coordinates.outputReference = gatewayFields.outputReference;
745
+ }
746
+ const cacheKey = AvoEventSpecCache_1.AvoEventSpecCache.makeKey(this.apiKey, anonymousId, eventName, coordinates);
243
747
  const cache = this.eventSpecCache;
244
748
  const validator = this.eventValidator;
245
749
  const doValidate = (specResponse) => {
@@ -249,52 +753,106 @@ class AvoInspector {
249
753
  }
250
754
  return null;
251
755
  }
252
- const eventProperties = eventSchema.map((prop) => (Object.assign({ propertyName: prop.propertyName, propertyType: prop.propertyType }, (rawEventProperties && rawEventProperties[prop.propertyName] !== undefined
253
- ? { propertyValue: String(rawEventProperties[prop.propertyName]) }
254
- : {}))));
756
+ const specEventCount = specResponse.eventSpec.events.length;
255
757
  if (AvoInspector.shouldLog) {
256
758
  console.log("Avo Inspector: Validating event: " + eventName
257
- + " with " + eventProperties.length + " properties"
258
- + " against " + specResponse.eventSpec.properties.length + " spec properties");
759
+ + " with " + eventSchema.length + " properties"
760
+ + " against " + specEventCount + " spec event" + (specEventCount === 1 ? "" : "s"));
259
761
  }
260
- const validationId = eventId || eventName;
261
- const results = validator.validate(specResponse.eventSpec, eventProperties, validationId);
762
+ const results = validator.validate(specResponse.eventSpec, eventSchema, rawEventProperties);
262
763
  if (AvoInspector.shouldLog) {
263
764
  console.log("Avo Inspector: Validation complete for event: " + eventName
264
- + " with " + results.length + " property results");
765
+ + " with " + results.length + " failed propert" + (results.length === 1 ? "y" : "ies"));
265
766
  }
266
767
  return { metadata: specResponse.metadata, propertyResults: results };
267
768
  };
268
769
  // Check cache first
269
770
  const cached = cache.get(cacheKey);
771
+ if (cached === null) {
772
+ if (AvoInspector.shouldLog) {
773
+ console.log("Avo Inspector: Event spec cache hit (empty) for event: " + eventName + ". Sending without validation.");
774
+ }
775
+ return null;
776
+ }
270
777
  if (cached !== undefined) {
271
778
  if (AvoInspector.shouldLog) {
272
779
  console.log("Avo Inspector: Event spec cache hit for event: " + eventName);
273
780
  }
274
781
  return doValidate(cached);
275
782
  }
276
- // Cache miss — fetch spec (process stays alive via trackPending)
783
+ // Cache miss — fetch spec (flush() awaits it via trackPending)
277
784
  if (AvoInspector.shouldLog) {
278
785
  console.log("Avo Inspector: Event spec cache miss for event: " + eventName + ". Fetching before sending.");
279
786
  }
787
+ if (AvoInspector.waitingValidations >= MAX_WAITING_VALIDATIONS) {
788
+ if (AvoInspector.shouldLog) {
789
+ console.log("Avo Inspector: " + MAX_WAITING_VALIDATIONS + " events are already waiting for an event spec. Sending " + eventName + " without validation.");
790
+ }
791
+ return null;
792
+ }
793
+ AvoInspector.waitingValidations++;
794
+ let waiting = true;
795
+ const release = () => {
796
+ if (waiting) {
797
+ waiting = false;
798
+ AvoInspector.waitingValidations--;
799
+ this.releaseWaiting.delete(release);
800
+ }
801
+ };
802
+ this.releaseWaiting.add(release);
280
803
  const fetcher = this.eventSpecFetcher;
281
- return new Promise((resolve) => {
282
- fetcher.fetch(eventName, anonymousId, (result) => {
804
+ return new Promise((resolve, reject) => {
805
+ const onSpec = (result, notFound) => {
806
+ release();
283
807
  if (result !== null) {
284
808
  cache.set(cacheKey, result);
285
- resolve(doValidate(result));
809
+ // A throw here would be swallowed by the fetcher and leave this promise pending;
810
+ // reject instead, so a miss falls back to an unvalidated send like a hit does.
811
+ try {
812
+ resolve(doValidate(result));
813
+ }
814
+ catch (err) {
815
+ reject(err);
816
+ }
286
817
  }
287
818
  else {
819
+ // Cached so a failing fetch is not repeated for every event: a 404 (no spec, such
820
+ // as a gateway key without originHint) for the spec TTL, any other failure briefly.
821
+ cache.set(cacheKey, null, notFound === true ? undefined : AvoEventSpecCache_1.TRANSIENT_FAILURE_TTL_MS);
288
822
  if (AvoInspector.shouldLog) {
289
823
  console.log("Avo Inspector: Event spec fetch returned null for event: " + eventName + ". Cached empty response. Sending without validation.");
290
824
  }
291
825
  resolve(null);
292
826
  }
293
- });
827
+ };
828
+ try {
829
+ fetcher.fetch(eventName, anonymousId, onSpec, coordinates);
830
+ }
831
+ catch (err) {
832
+ // No callback will come: free the slot and fall back to an unvalidated send.
833
+ release();
834
+ reject(err);
835
+ }
294
836
  });
295
837
  });
296
838
  }
839
+ /**
840
+ * Cancels and cleans up: discards the pending batch unsent, abandons in-flight
841
+ * requests and stops the flush timer. Does not flush. After destroy() the instance
842
+ * is terminated and trackSchemaFromEvent resolves [] without sending; a call still
843
+ * waiting on a spec fetch or on its immediate send also resolves [].
844
+ */
297
845
  destroy() {
846
+ this.destroyed = true;
847
+ const waiters = Array.from(this.destroyWaiters);
848
+ this.destroyWaiters.clear();
849
+ waiters.forEach((settle) => settle());
850
+ this.batchQueue.clear();
851
+ this.pending.clear();
852
+ this.validations.clear();
853
+ Array.from(this.releaseWaiting).forEach((release) => release());
854
+ this.updateExitDrain();
855
+ this.avoNetworkCallsHandler.abortInFlight();
298
856
  if (this.eventSpecFetcher) {
299
857
  this.eventSpecFetcher.destroy();
300
858
  this.eventSpecFetcher = null;
@@ -304,12 +862,122 @@ class AvoInspector {
304
862
  this.eventSpecCache = null;
305
863
  }
306
864
  this.eventValidator = null;
307
- if (this.keepAliveTimer !== null) {
308
- clearInterval(this.keepAliveTimer);
309
- this.keepAliveTimer = null;
310
- }
311
- this.pendingCount = 0;
865
+ // Destroy is final: report every pending count now.
866
+ AvoLog_1.AvoLog.flushPending();
312
867
  }
313
868
  }
314
869
  exports.AvoInspector = AvoInspector;
870
+ // Best-effort delivery at exit: when the event loop empties ("beforeExit"), every
871
+ // instance with buffered or in-flight events is flushed. One listener serves all
872
+ // instances, and only instances with work are registered, so idle instances are not
873
+ // retained. The listener schedules nothing when there is nothing to send, so it never
874
+ // keeps the process alive on its own. "beforeExit" does not fire on process.exit() or
875
+ // on signals; callers flush() there.
876
+ AvoInspector.instancesWithWork = new Set();
877
+ AvoInspector.exitDrainArmed = false;
878
+ AvoInspector.exitReportArmed = false;
879
+ // One deadline for the whole exit, however often "beforeExit" fires during it. Cleared
880
+ // once every instance has drained, in case the process carries on.
881
+ AvoInspector.exitDeadline = null;
882
+ // Events waiting for a spec fetch, across every instance (at most MAX_WAITING_VALIDATIONS).
883
+ AvoInspector.waitingValidations = 0;
884
+ // When any instance last tracked an event (monotonic clock).
885
+ AvoInspector.lastTrackAt = -Infinity;
886
+ // The exit deadline whose validation grace has been used.
887
+ AvoInspector.validationGraceUsedFor = null;
888
+ // Deadlines of explicit flush() calls still running, by call.
889
+ AvoInspector.explicitFlushDeadlines = new Map();
890
+ AvoInspector.drainOnExit = () => {
891
+ AvoInspector.exitDrainArmed = false;
892
+ const instances = Array.from(AvoInspector.instancesWithWork);
893
+ if (instances.length === 0) {
894
+ return;
895
+ }
896
+ const now = (0, utils_1.monotonicNowMs)();
897
+ // One deadline per exit: "beforeExit" re-fires while the same exit continues (also when
898
+ // the app's own beforeExit work keeps the loop alive past the deadline), so a hung
899
+ // endpoint cannot stretch it. Only a track after the deadline passed shows the process
900
+ // carried on from that exit; the next one then gets a new deadline.
901
+ const deadlinePassed = AvoInspector.exitDeadline !== null && now > AvoInspector.exitDeadline;
902
+ if (AvoInspector.exitDeadline === null ||
903
+ (deadlinePassed && AvoInspector.lastTrackAt > AvoInspector.exitDeadline)) {
904
+ AvoInspector.exitDeadline = now + DEFAULT_FLUSH_TIMEOUT_MS;
905
+ }
906
+ // An explicit flush() still running gets its full deadline, even past the drain's own.
907
+ let deadline = AvoInspector.exitDeadline;
908
+ AvoInspector.explicitFlushDeadlines.forEach((flushDeadline) => {
909
+ deadline = Math.max(deadline, flushDeadline);
910
+ });
911
+ const remaining = deadline - now;
912
+ if (remaining <= 0) {
913
+ // Out of time: let the process exit; what is still unsent is reported at the exit.
914
+ return;
915
+ }
916
+ // Spec-fetch sockets are unref'd, so "beforeExit" also fires while a script awaits a
917
+ // track whose spec is being fetched. Once per deadline, give pending validations a short
918
+ // grace, sending nothing: with a working spec endpoint they settle well within it, the
919
+ // script carries on and its batches stay whole. Settled in time with no fetch given up
920
+ // counts as a completed idle point (the deadline is renewed); otherwise the grace is
921
+ // spent and the next "beforeExit" drains.
922
+ const validations = instances.reduce((all, inspector) => all.concat(Array.from(inspector.validationOutcomes)), []);
923
+ if (validations.length > 0 && AvoInspector.validationGraceUsedFor !== AvoInspector.exitDeadline) {
924
+ AvoInspector.validationGraceUsedFor = AvoInspector.exitDeadline;
925
+ const exitDeadline = AvoInspector.exitDeadline;
926
+ const timeoutsBefore = AvoEventSpecFetcher_1.AvoEventSpecFetcher.timeouts;
927
+ let grace;
928
+ const graceOver = new Promise((resolve) => {
929
+ grace = setTimeout(() => resolve(false), Math.min(VALIDATION_GRACE_MS, remaining));
930
+ });
931
+ Promise.race([Promise.allSettled(validations).then(() => true), graceOver]).then((settled) => {
932
+ clearTimeout(grace);
933
+ if (settled && (0, utils_1.monotonicNowMs)() < exitDeadline && AvoEventSpecFetcher_1.AvoEventSpecFetcher.timeouts === timeoutsBefore) {
934
+ AvoInspector.exitDeadline = null;
935
+ }
936
+ });
937
+ AvoInspector.armExitDrain();
938
+ return;
939
+ }
940
+ // Request sockets are unref'd, so this timer is what keeps the process alive while
941
+ // everything left is sent at once (within the in-flight cap). Every instance drains
942
+ // what is ready now; events still being validated follow as they are validated, and
943
+ // halfway through the budget the validations still pending are given up, so those
944
+ // events go out unvalidated within the rest of it.
945
+ const keepAlive = setTimeout(() => { }, remaining);
946
+ const giveUp = setTimeout(() => {
947
+ instances.forEach((inspector) => inspector.giveUpValidations());
948
+ }, remaining / 2);
949
+ giveUp.unref();
950
+ Promise.all(instances.map((inspector) => inspector.flushWithin(remaining))).then((completed) => {
951
+ clearTimeout(keepAlive);
952
+ clearTimeout(giveUp);
953
+ // Finished before the budget ran out: the next "beforeExit" starts a new exit with a
954
+ // fresh deadline. Decided by which won the race, not by reading the clock: under load
955
+ // a timer can fire while the monotonic clock still reads just short of the deadline,
956
+ // and a timed-out drain would then be renewed for another 10 s.
957
+ if (completed.every((done) => done)) {
958
+ AvoInspector.exitDeadline = null;
959
+ }
960
+ });
961
+ // Re-arm so work added during the drain is sent at the next "beforeExit".
962
+ AvoInspector.armExitDrain();
963
+ };
964
+ // At the real exit (natural, or process.exit(); signals do not emit it), reports what
965
+ // every instance with work still holds, synchronously: events never sent (buffered,
966
+ // waiting for a slot, or still being validated) and events in sends not yet completed.
967
+ AvoInspector.reportOnExit = () => {
968
+ let unsent = 0;
969
+ let unconfirmed = 0;
970
+ AvoInspector.instancesWithWork.forEach((inspector) => {
971
+ unsent += inspector.batchQueue.length + inspector.batchQueue.waitingLength + inspector.validationOutcomes.size;
972
+ unconfirmed += inspector.batchQueue.inFlightEvents;
973
+ });
974
+ AvoLog_1.AvoLog.enterExit();
975
+ if (unsent > 0) {
976
+ AvoLog_1.AvoLog.dropped(unsent, "unsent at exit");
977
+ }
978
+ if (unconfirmed > 0) {
979
+ AvoLog_1.AvoLog.dropped(unconfirmed, "unconfirmed at exit");
980
+ }
981
+ AvoLog_1.AvoLog.flushPending();
982
+ };
315
983
  AvoInspector._shouldLog = false;