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

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.
@@ -2,43 +2,181 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AvoEventSpecFetcher = void 0;
4
4
  const https_1 = require("https");
5
+ const http_1 = require("http");
6
+ const AvoEventSpecCache_1 = require("./AvoEventSpecCache");
5
7
  const AvoInspector_1 = require("../AvoInspector");
8
+ const AvoLog_1 = require("../AvoLog");
9
+ // Nesting the parser follows in a spec's children; deeper levels are left empty. Validation
10
+ // itself stops at the second level (EventValidator), so this only bounds the parse.
11
+ const MAX_PARSE_DEPTH = 10;
12
+ const isWireObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
13
+ // A constraint key -> event IDs mapping; IDs that are not strings, and keys left with none,
14
+ // are dropped. Undefined when nothing is left.
15
+ const parseIdMapping = (wire) => {
16
+ if (!isWireObject(wire)) {
17
+ return undefined;
18
+ }
19
+ const result = Object.create(null);
20
+ let found = false;
21
+ for (const key of Object.keys(wire)) {
22
+ const ids = wire[key];
23
+ if (!Array.isArray(ids)) {
24
+ continue;
25
+ }
26
+ const eventIds = ids.filter((id) => typeof id === "string");
27
+ if (eventIds.length > 0) {
28
+ result[key] = eventIds;
29
+ found = true;
30
+ }
31
+ }
32
+ return found ? result : undefined;
33
+ };
34
+ // One keep-alive agent for every instance, so creating many instances (for example one per
35
+ // request) cannot pile up idle TLS sockets. Idle sockets are capped and do not hold the
36
+ // process open.
37
+ const sharedAgent = new https_1.Agent({ keepAlive: true, maxSockets: 8, maxFreeSockets: 2 });
38
+ // The same, for an http: mock endpoint.
39
+ const sharedHttpAgent = new http_1.Agent({ keepAlive: true, maxSockets: 8, maxFreeSockets: 2 });
6
40
  class AvoEventSpecFetcher {
7
- constructor(apiKey) {
41
+ /** `mockEndpoint`: a valid override URL (AvoNetworkCallsHandler.mockEndpoint), or null. */
42
+ constructor(apiKey, mockEndpoint = null) {
43
+ // Where spec requests go: api.avo.app, or the origin of the mock-endpoint override.
44
+ this.protocol = "https:";
45
+ this.hostname = "api.avo.app";
46
+ this.port = 443;
47
+ // Scheme, host and port (when not the default) for log lines: never the override's path.
48
+ this.origin = "https://api.avo.app";
8
49
  this.inFlight = new Map();
50
+ this.agent = sharedAgent;
51
+ this.requests = new Set();
9
52
  this.apiKey = apiKey;
10
- this.agent = new https_1.Agent({ keepAlive: true });
53
+ if (mockEndpoint !== null) {
54
+ const url = new URL(mockEndpoint);
55
+ this.protocol = url.protocol === "http:" ? "http:" : "https:";
56
+ // URL keeps the brackets of an IPv6 host; request() takes the bare address.
57
+ this.hostname = url.hostname.replace(/^\[(.*)\]$/, "$1");
58
+ this.port = url.port ? Number(url.port) : this.protocol === "http:" ? 80 : 443;
59
+ this.origin = this.protocol + "//" + url.host;
60
+ }
11
61
  }
12
- fetch(eventName, streamId, callback) {
13
- const dedupeKey = `${this.apiKey}:${streamId}:${eventName}`;
62
+ /**
63
+ * `coordinates`: the event's gateway coordinates. Each non-blank one is sent as a query
64
+ * parameter and is part of the dedupe key; without any, the request and key are those of
65
+ * a plain source fetch.
66
+ */
67
+ fetch(eventName, streamId, callback, coordinates) {
68
+ var _a;
69
+ const dedupeKey = (_a = (0, AvoEventSpecCache_1.coordinatesKey)([this.apiKey, streamId, eventName], coordinates)) !== null && _a !== void 0 ? _a : `${this.apiKey}:${streamId}:${eventName}`;
14
70
  // In-flight dedup: if there's already a request in flight for this key,
15
71
  // queue the callback
16
- const existingCallbacks = this.inFlight.get(dedupeKey);
17
- if (existingCallbacks) {
18
- existingCallbacks.push(callback);
72
+ const existing = this.inFlight.get(dedupeKey);
73
+ if (existing) {
74
+ existing.callbacks.push(callback);
19
75
  return;
20
76
  }
21
77
  // Register the callback and start the request
22
- this.inFlight.set(dedupeKey, [callback]);
23
- const queryParams = new URLSearchParams({
24
- apiKey: this.apiKey,
25
- eventName,
26
- streamId,
27
- });
28
- const options = {
29
- hostname: "api.avo.app",
30
- port: 443,
31
- path: `${AvoEventSpecFetcher.specEndpoint}?${queryParams.toString()}`,
32
- method: "GET",
33
- agent: this.agent,
34
- headers: {
35
- Accept: "application/json",
36
- },
78
+ const owner = {};
79
+ this.inFlight.set(dedupeKey, { callbacks: [callback], owner });
80
+ // A request settles its key once. A late event (for example the error from a request
81
+ // destroyed on timeout) must not settle a newer fetch that has taken the key since.
82
+ // `deferCallbacks` is for settling inside fetch() itself: the key is freed and the
83
+ // deadline cleared at once, but callbacks still run asynchronously.
84
+ let settled = false;
85
+ let deadline;
86
+ const settle = (result, deferCallbacks = false, notFound = false) => {
87
+ if (settled) {
88
+ return;
89
+ }
90
+ settled = true;
91
+ clearTimeout(deadline);
92
+ this.resolveCallbacks(dedupeKey, owner, result, deferCallbacks, result === null && notFound);
37
93
  };
38
- if (AvoInspector_1.AvoInspector.shouldLog) {
39
- console.log("Avo Inspector: [network] GET https://" + options.hostname + AvoEventSpecFetcher.specEndpoint + "?eventName=" + encodeURIComponent(eventName));
94
+ // Anything that throws from here on would leave the key registered with no request
95
+ // behind it, and every later fetch of the key would wait on it forever.
96
+ let req;
97
+ try {
98
+ const queryParams = new URLSearchParams({
99
+ apiKey: this.apiKey,
100
+ eventName,
101
+ streamId,
102
+ });
103
+ // Appended after the source params, so a fetch without coordinates is unchanged.
104
+ const { originHint, outputReference } = (0, AvoEventSpecCache_1.normalizeCoordinates)(coordinates);
105
+ if (originHint !== undefined) {
106
+ queryParams.append("originHint", originHint);
107
+ }
108
+ if (outputReference !== undefined) {
109
+ queryParams.append("outputReference", outputReference);
110
+ }
111
+ const options = {
112
+ hostname: this.hostname,
113
+ port: this.port,
114
+ path: `${AvoEventSpecFetcher.specEndpoint}?${queryParams.toString()}`,
115
+ method: "GET",
116
+ agent: this.protocol === "http:" ? sharedHttpAgent : this.agent,
117
+ headers: {
118
+ Accept: "application/json",
119
+ },
120
+ };
121
+ if (AvoInspector_1.AvoInspector.shouldLog) {
122
+ console.log("Avo Inspector: [network] GET " + this.origin + AvoEventSpecFetcher.specEndpoint + "?eventName=" + eventName);
123
+ }
124
+ const sent = this.send(options, eventName, settle);
125
+ req = sent;
126
+ this.requests.add(sent);
127
+ // `deadline` is first the wait for a socket, then the fetch's own budget; settling
128
+ // clears whichever is armed.
129
+ deadline = setTimeout(() => {
130
+ if (AvoInspector_1.AvoInspector.shouldLog) {
131
+ console.error("Avo Inspector: [network] Spec fetch got no connection within " + AvoEventSpecFetcher.socketWaitTimeoutMs + "ms");
132
+ }
133
+ AvoEventSpecFetcher.timeouts++;
134
+ sent.destroy();
135
+ settle(null);
136
+ }, AvoEventSpecFetcher.socketWaitTimeoutMs);
137
+ deadline.unref();
138
+ // Unref'd like track sockets, so a fetch never holds the process open by itself: at
139
+ // exit, a pending validation is awaited by the exit drain, within its deadline. On
140
+ // every assignment, since the keep-alive agent re-refs a socket when it reuses it.
141
+ sent.on("socket", (socket) => socket.unref());
142
+ sent.once("socket", () => {
143
+ if (settled) {
144
+ return;
145
+ }
146
+ clearTimeout(deadline);
147
+ deadline = setTimeout(() => {
148
+ if (AvoInspector_1.AvoInspector.shouldLog) {
149
+ console.error("Avo Inspector: [network] Spec fetch timed out after " + AvoEventSpecFetcher.fetchTimeoutMs + "ms");
150
+ }
151
+ AvoEventSpecFetcher.timeouts++;
152
+ sent.destroy();
153
+ settle(null);
154
+ }, AvoEventSpecFetcher.fetchTimeoutMs);
155
+ deadline.unref();
156
+ });
157
+ sent.on("close", () => {
158
+ clearTimeout(deadline);
159
+ this.requests.delete(sent);
160
+ // A request destroyed before it got a socket can close with no response and no
161
+ // error. settle is idempotent and owner-checked, so this is a no-op otherwise.
162
+ settle(null);
163
+ });
164
+ sent.end();
40
165
  }
41
- const req = (0, https_1.request)(options, (res) => {
166
+ catch (e) {
167
+ if (AvoInspector_1.AvoInspector.shouldLog) {
168
+ console.error("Avo Inspector: [network] Spec fetch error (" + AvoLog_1.AvoLog.errorType(e) + ")");
169
+ }
170
+ if (req) {
171
+ req.destroy();
172
+ }
173
+ settle(null, true);
174
+ }
175
+ }
176
+ // Starts the GET; every outcome (response, parse failure, transport error) goes to settle.
177
+ send(options, eventName, settle) {
178
+ const send = this.protocol === "http:" ? http_1.request : https_1.request;
179
+ const req = send(options, (res) => {
42
180
  if (AvoInspector_1.AvoInspector.shouldLog) {
43
181
  console.log("Avo Inspector: [network] Spec response status: " + res.statusCode + " " + res.statusMessage);
44
182
  }
@@ -51,7 +189,7 @@ class AvoEventSpecFetcher {
51
189
  const body = Buffer.concat(chunks).toString();
52
190
  console.warn("Avo Inspector: [network] Spec fetch failed with status " + res.statusCode + ": " + body);
53
191
  }
54
- this.resolveCallbacks(dedupeKey, null);
192
+ settle(null, false, res.statusCode === 404);
55
193
  return;
56
194
  }
57
195
  try {
@@ -70,90 +208,162 @@ class AvoEventSpecFetcher {
70
208
  console.warn("Avo Inspector: [network] Failed to parse spec response: " + e);
71
209
  }
72
210
  }
73
- this.resolveCallbacks(dedupeKey, result);
211
+ settle(result);
74
212
  });
213
+ // A response cut off mid-body emits neither "end" nor a request "error"; without this
214
+ // the fetch would wait for its deadline, holding the key for every same-key fetch.
215
+ const truncated = () => {
216
+ if (!res.complete) {
217
+ if (AvoInspector_1.AvoInspector.shouldLog) {
218
+ console.error("Avo Inspector: [network] Spec response ended before its body was complete");
219
+ }
220
+ settle(null);
221
+ }
222
+ };
223
+ res.on("aborted", truncated);
224
+ res.on("error", truncated);
225
+ res.on("close", truncated);
75
226
  });
76
227
  req.on("error", (err) => {
77
228
  if (AvoInspector_1.AvoInspector.shouldLog) {
78
229
  console.error("Avo Inspector: [network] Spec fetch error: " + err);
79
230
  }
80
- this.resolveCallbacks(dedupeKey, null);
231
+ settle(null);
81
232
  });
82
- req.setTimeout(10000);
83
- req.on("timeout", () => {
84
- if (AvoInspector_1.AvoInspector.shouldLog) {
85
- console.error("Avo Inspector: [network] Spec fetch timed out after 10s");
86
- }
87
- req.destroy();
88
- this.resolveCallbacks(dedupeKey, null);
89
- });
90
- req.end();
233
+ return req;
91
234
  }
92
- resolveCallbacks(key, result) {
93
- const callbacks = this.inFlight.get(key);
235
+ resolveCallbacks(key, owner, result, deferCallbacks = false, notFound = false) {
236
+ const pending = this.inFlight.get(key);
237
+ if (!pending || pending.owner !== owner) {
238
+ return;
239
+ }
94
240
  this.inFlight.delete(key);
95
- if (callbacks) {
96
- for (const cb of callbacks) {
241
+ const invoke = () => {
242
+ for (const cb of pending.callbacks) {
97
243
  try {
98
- cb(result);
244
+ cb(result, notFound);
99
245
  }
100
246
  catch (e) {
101
247
  // Don't let one callback failure affect others
102
248
  }
103
249
  }
250
+ };
251
+ if (deferCallbacks) {
252
+ process.nextTick(invoke);
253
+ }
254
+ else {
255
+ invoke();
104
256
  }
105
257
  }
106
258
  /**
107
259
  * Parse the wire format from /trackingPlan/eventSpec into our internal types.
108
260
  *
109
261
  * Wire format:
110
- * { events: [{ b, id, vids, p: { "PropName": { t: "string", r: true, v: {...}, rx: {...} } } }],
262
+ * { events: [{ b, id, vids, p: { "PropName": { t, r, l?, p?, v?, rx?, minmax?, children? } } }],
111
263
  * metadata: { schemaId, branchId, latestActionId, sourceId } }
264
+ * where p, v, rx and minmax each map a constraint key to the event IDs that require it.
112
265
  *
113
266
  * Internal format:
114
- * { eventSpec: { eventName, properties: [{ propertyName, propertyType, regex? }] } | null,
267
+ * { eventSpec: { eventName, events: [{ branchId, baseEventId, variantIds, props }] } | null,
115
268
  * metadata: { schemaId, branchId, latestActionId, sourceId } }
116
269
  */
117
270
  static parseWireResponse(wire, eventName) {
118
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
271
+ var _a, _b, _c, _d, _e, _f, _g, _h;
119
272
  const metadata = {
120
273
  schemaId: (_b = (_a = wire.metadata) === null || _a === void 0 ? void 0 : _a.schemaId) !== null && _b !== void 0 ? _b : "",
121
274
  branchId: (_d = (_c = wire.metadata) === null || _c === void 0 ? void 0 : _c.branchId) !== null && _d !== void 0 ? _d : "",
122
275
  latestActionId: (_f = (_e = wire.metadata) === null || _e === void 0 ? void 0 : _e.latestActionId) !== null && _f !== void 0 ? _f : "",
123
276
  sourceId: (_h = (_g = wire.metadata) === null || _g === void 0 ? void 0 : _g.sourceId) !== null && _h !== void 0 ? _h : "",
124
277
  };
125
- if (!wire.events || !Array.isArray(wire.events) || wire.events.length === 0) {
278
+ const events = [];
279
+ if (Array.isArray(wire.events)) {
280
+ for (const entry of wire.events) {
281
+ const parsed = AvoEventSpecFetcher.parseEventEntry(entry);
282
+ if (parsed !== null) {
283
+ events.push(parsed);
284
+ }
285
+ }
286
+ }
287
+ if (events.length === 0) {
126
288
  return { eventSpec: null, metadata };
127
289
  }
128
- // Use the first event entry (the endpoint returns specs for the requested event)
129
- const entry = wire.events[0];
130
- const properties = [];
131
- if (entry.p && typeof entry.p === "object") {
132
- for (const propName of Object.keys(entry.p)) {
133
- const constraint = entry.p[propName];
134
- const prop = {
135
- propertyName: propName,
136
- propertyType: (_j = constraint === null || constraint === void 0 ? void 0 : constraint.t) !== null && _j !== void 0 ? _j : "unknown",
137
- };
138
- // Extract regex pattern if present (rx field maps regex patterns to event IDs)
139
- if ((constraint === null || constraint === void 0 ? void 0 : constraint.rx) && typeof constraint.rx === "object") {
140
- const patterns = Object.keys(constraint.rx);
141
- if (patterns.length > 0) {
142
- prop.regex = patterns[0];
143
- }
144
- }
145
- properties.push(prop);
290
+ return { eventSpec: { eventName, events }, metadata };
291
+ }
292
+ // An entry without a string id has no event to attribute results to, so it is skipped.
293
+ static parseEventEntry(entry) {
294
+ if (!isWireObject(entry) || typeof entry.id !== "string") {
295
+ return null;
296
+ }
297
+ return {
298
+ branchId: typeof entry.b === "string" ? entry.b : "",
299
+ baseEventId: entry.id,
300
+ variantIds: Array.isArray(entry.vids)
301
+ ? entry.vids.filter((id) => typeof id === "string")
302
+ : [],
303
+ props: AvoEventSpecFetcher.parseConstraintsByName(entry.p, 0),
304
+ };
305
+ }
306
+ // Null-prototype, so a property named "constructor" or "__proto__" is an ordinary key.
307
+ static parseConstraintsByName(wire, depth) {
308
+ const result = Object.create(null);
309
+ if (!isWireObject(wire) || depth >= MAX_PARSE_DEPTH) {
310
+ return result;
311
+ }
312
+ for (const name of Object.keys(wire)) {
313
+ const constraints = AvoEventSpecFetcher.parseConstraints(wire[name], depth);
314
+ if (constraints !== null) {
315
+ result[name] = constraints;
146
316
  }
147
317
  }
148
- const eventSpec = {
149
- eventName,
150
- properties,
318
+ return result;
319
+ }
320
+ static parseConstraints(wire, depth) {
321
+ if (!isWireObject(wire)) {
322
+ return null;
323
+ }
324
+ const constraints = {
325
+ type: typeof wire.t === "string" ? wire.t : "unknown",
326
+ required: wire.r === true,
151
327
  };
152
- return { eventSpec, metadata };
328
+ if (wire.l === true) {
329
+ constraints.isList = true;
330
+ }
331
+ const pinnedValues = parseIdMapping(wire.p);
332
+ if (pinnedValues) {
333
+ constraints.pinnedValues = pinnedValues;
334
+ }
335
+ const allowedValues = parseIdMapping(wire.v);
336
+ if (allowedValues) {
337
+ constraints.allowedValues = allowedValues;
338
+ }
339
+ const regexPatterns = parseIdMapping(wire.rx);
340
+ if (regexPatterns) {
341
+ constraints.regexPatterns = regexPatterns;
342
+ }
343
+ const minMaxRanges = parseIdMapping(wire.minmax);
344
+ if (minMaxRanges) {
345
+ constraints.minMaxRanges = minMaxRanges;
346
+ }
347
+ if (isWireObject(wire.children)) {
348
+ constraints.children = AvoEventSpecFetcher.parseConstraintsByName(wire.children, depth + 1);
349
+ }
350
+ return constraints;
153
351
  }
352
+ /** Aborts this instance's in-flight requests; the shared agent stays usable. */
154
353
  destroy() {
155
- this.agent.destroy();
354
+ const requests = Array.from(this.requests);
355
+ this.requests.clear();
356
+ requests.forEach((req) => req.destroy());
156
357
  }
157
358
  }
158
359
  exports.AvoEventSpecFetcher = AvoEventSpecFetcher;
159
360
  AvoEventSpecFetcher.specEndpoint = "/trackingPlan/eventSpec";
361
+ // Budget per fetch, from the moment the shared agent assigns it a socket. Counting from
362
+ // the request instead would expire fetches queued behind 8 slow ones before they were
363
+ // ever sent.
364
+ AvoEventSpecFetcher.fetchTimeoutMs = 10000;
365
+ // Budget for the wait before a socket is assigned. Without it, fetches queued behind 8
366
+ // hung ones would wait for every earlier deadline in turn.
367
+ AvoEventSpecFetcher.socketWaitTimeoutMs = 10000;
368
+ /** @internal Fetches given up at a deadline, process-wide: the exit drain reads it. */
369
+ AvoEventSpecFetcher.timeouts = 0;
@@ -1,15 +1,21 @@
1
1
  import { EventSpec, PropertyValidationResult } from "./AvoEventSpecFetchTypes";
2
- interface EventProperty {
3
- propertyName: string;
4
- propertyType: string;
5
- propertyValue?: string;
6
- }
2
+ /**
3
+ * Checks event property values against the value constraints of an event spec: pinned values,
4
+ * allowed values, regex patterns and min/max ranges, on a property and on the properties of an
5
+ * object (or of each object in a list).
6
+ *
7
+ * Each constraint applies to the tracking-plan event IDs (base event and variants) the spec
8
+ * lists for it, so a value can pass for one variant and fail for another. Types and required
9
+ * properties are not checked here; Avo compares the reported schema with the plan for those.
10
+ */
7
11
  export declare class EventValidator {
8
12
  /**
9
- * Validates event properties against the spec.
10
- * Returns per-property validation results with bandwidth optimization:
11
- * passedEventIds is returned only when strictly smaller than failedEventIds.
13
+ * Returns one result per property that failed for at least one event ID, or whose object
14
+ * properties did. Properties the spec has no constraints for are left out.
12
15
  */
13
- validate(spec: EventSpec, eventProperties: EventProperty[], eventId: string): PropertyValidationResult[];
16
+ validate(spec: EventSpec, eventSchema: Array<{
17
+ propertyName: string;
18
+ }>, rawEventProperties: {
19
+ [propName: string]: any;
20
+ } | undefined): PropertyValidationResult[];
14
21
  }
15
- export {};