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.
@@ -4,103 +4,242 @@ exports.AvoNetworkCallsHandler = void 0;
4
4
  const AvoGuid_1 = require("./AvoGuid");
5
5
  const AvoInspector_1 = require("./AvoInspector");
6
6
  const AvoEncryption_1 = require("./AvoEncryption");
7
+ const AvoLog_1 = require("./AvoLog");
8
+ const AvoInspectorVersion_1 = require("./AvoInspectorVersion");
9
+ const utils_1 = require("./utils");
7
10
  const https_1 = require("https");
11
+ const http_1 = require("http");
12
+ const zlib_1 = require("zlib");
8
13
  class AvoNetworkCallsHandler {
9
14
  constructor(apiKey, envName, appName, appVersion, libVersion, publicEncryptionKey) {
10
15
  this.samplingRate = 1.0;
16
+ this.inFlightRequests = new Set();
17
+ this.aborted = false;
11
18
  this.apiKey = apiKey;
12
19
  this.envName = envName;
13
20
  this.appName = appName;
14
21
  this.appVersion = appVersion;
15
22
  this.libVersion = libVersion;
16
23
  this.publicEncryptionKey = publicEncryptionKey;
24
+ this.mockEndpoint = AvoNetworkCallsHandler.mockEndpointFor(envName);
17
25
  }
26
+ /**
27
+ * The test-only endpoint override. Fail-closed: a prod instance never honors it,
28
+ * whatever the surrounding process environment says. A value that is not an http(s)
29
+ * URL is ignored (with a one-time warning), so callers only ever see a valid URL.
30
+ */
31
+ static mockEndpointFor(envName) {
32
+ if (envName === "prod") {
33
+ return null;
34
+ }
35
+ const override = process.env[AvoNetworkCallsHandler.mockEndpointEnvVar];
36
+ if (!override) {
37
+ return null;
38
+ }
39
+ // The warnings name the reason only, never the value: it may carry credentials or tokens.
40
+ // The value is used solely as the key that keeps each warning to one per value.
41
+ let url;
42
+ try {
43
+ url = new URL(override);
44
+ }
45
+ catch (e) {
46
+ AvoNetworkCallsHandler.warnOnce("invalid:" + override, "[Avo Inspector] Ignoring invalid AVO_INSPECTOR_MOCK_ENDPOINT: not a valid URL");
47
+ return null;
48
+ }
49
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
50
+ AvoNetworkCallsHandler.warnOnce("invalid:" + override, "[Avo Inspector] Ignoring invalid AVO_INSPECTOR_MOCK_ENDPOINT: unsupported protocol " + url.protocol);
51
+ return null;
52
+ }
53
+ return override;
54
+ }
55
+ // Printed on the first send redirected to the override.
56
+ static warnRedirect(url) {
57
+ // Scheme, host and port only: the path or query may carry sensitive values.
58
+ AvoNetworkCallsHandler.warnOnce("redirect", "[Avo Inspector] AVO_INSPECTOR_MOCK_ENDPOINT is set: sending to " + url.protocol + "//" +
59
+ url.host + " instead of api.avo.app (ignored in prod).");
60
+ }
61
+ static warnOnce(key, message) {
62
+ if (AvoNetworkCallsHandler.warnedMockEndpoint === null) {
63
+ AvoNetworkCallsHandler.warnedMockEndpoint = new Set();
64
+ }
65
+ if (!AvoNetworkCallsHandler.warnedMockEndpoint.has(key)) {
66
+ AvoNetworkCallsHandler.warnedMockEndpoint.add(key);
67
+ console.warn(message);
68
+ }
69
+ }
70
+ /** The sampling rate last returned by the Inspector API (1 until a response sets it). */
71
+ getSamplingRate() {
72
+ return this.samplingRate;
73
+ }
74
+ /** @internal Test-only hook used by the conformance harness. */
75
+ _setSamplingRateForTesting(samplingRate) {
76
+ this.samplingRate = samplingRate;
77
+ }
78
+ /** Aborts every request still in flight (used by AvoInspector.destroy). */
79
+ abortInFlight() {
80
+ // Also stops sends still being compressed from starting a request afterwards.
81
+ this.aborted = true;
82
+ const requests = Array.from(this.inFlightRequests);
83
+ this.inFlightRequests.clear();
84
+ requests.forEach((req) => req.destroy());
85
+ }
86
+ /**
87
+ * POSTs one batch. Resolves with the HTTP status code (the batch is never retried);
88
+ * rejects with "Request failed" / "Request timed out" on transport failures, or when
89
+ * a header value cannot be transmitted safely.
90
+ */
18
91
  callInspectorWithBatchBody(inEvents) {
19
92
  const events = inEvents.filter((x) => x != null);
20
93
  if (events.length === 0) {
21
94
  return Promise.resolve();
22
95
  }
23
- if (Math.random() > this.samplingRate) {
24
- if (AvoInspector_1.AvoInspector.shouldLog) {
25
- console.log("Avo Inspector: last event schema dropped due to sampling rate.");
26
- }
27
- return Promise.resolve();
28
- }
29
96
  if (AvoInspector_1.AvoInspector.shouldLog) {
30
97
  events.forEach(function (event) {
31
- const eventProps = event.eventProperties
32
- .map(p => '\t"' + p.propertyName + '": "' + p.propertyType + '"')
33
- .join(";\n");
34
98
  const validated = event.eventSpecMetadata ? " (validated)" : "";
35
99
  console.log("Avo Inspector: Sending event " +
36
100
  event.eventName + validated +
37
- " with schema {\n" + eventProps + "\n}");
101
+ " with schema " + (0, utils_1.formatSchema)(event.eventProperties));
38
102
  });
39
103
  }
104
+ const json = Buffer.from(JSON.stringify(events), "utf8");
105
+ // Compressed off the event loop; on a compression error the body is sent as-is.
106
+ const body = json.length >= AvoNetworkCallsHandler.gzipThresholdBytes
107
+ ? AvoNetworkCallsHandler.gzipAsync(json).then((data) => ({ data, compressed: true }), () => ({ data: json, compressed: false }))
108
+ : Promise.resolve({ data: json, compressed: false });
109
+ return body.then(({ data, compressed }) => this.post(data, compressed));
110
+ }
111
+ static gzipAsync(input) {
40
112
  return new Promise((resolve, reject) => {
41
- const data = JSON.stringify(events);
42
- const options = {
43
- hostname: "api.avo.app",
44
- port: 443,
45
- path: AvoNetworkCallsHandler.trackingEndpoint,
46
- method: "POST",
47
- headers: {
48
- "Accept": "application/json",
49
- "Content-Type": "application/json",
50
- "Content-Length": Buffer.byteLength(data),
51
- },
113
+ (0, zlib_1.gzip)(input, (err, output) => (err ? reject(err) : resolve(output)));
114
+ });
115
+ }
116
+ post(data, compressed) {
117
+ return new Promise((resolve, reject) => {
118
+ if (this.aborted) {
119
+ reject("Request failed");
120
+ return;
121
+ }
122
+ const headers = {
123
+ "api-key": this.apiKey,
124
+ "env": this.envName,
125
+ "X-Avo-Client": AvoInspectorVersion_1.LIB_PLATFORM,
126
+ "Accept": "application/json",
127
+ "Content-Type": "application/json",
128
+ "Content-Length": data.length,
52
129
  };
130
+ if (compressed) {
131
+ headers["Content-Encoding"] = "gzip";
132
+ }
133
+ for (const name of Object.keys(headers)) {
134
+ const value = headers[name];
135
+ if (typeof value === "string" && ((0, utils_1.hasHeaderControlChar)(value) || (0, utils_1.hasNonLatin1Char)(value))) {
136
+ if (AvoInspector_1.AvoInspector.shouldLog) {
137
+ console.error("Avo Inspector: [network] Header " + name +
138
+ " contains a character that cannot be sent in a header. Batch dropped.");
139
+ }
140
+ reject("Request failed");
141
+ return;
142
+ }
143
+ }
144
+ const url = new URL(this.mockEndpoint || AvoNetworkCallsHandler.trackingEndpoint);
145
+ if (this.mockEndpoint) {
146
+ AvoNetworkCallsHandler.warnRedirect(url);
147
+ }
148
+ const send = url.protocol === "http:" ? http_1.request : https_1.request;
53
149
  if (AvoInspector_1.AvoInspector.shouldLog) {
54
- console.log("Avo Inspector: [network] POST https://" + options.hostname + options.path);
55
- console.log("Avo Inspector: [network] Request body (" + Buffer.byteLength(data) + " bytes)");
150
+ // An override's path may carry a token, so only its origin is printed.
151
+ console.log("Avo Inspector: [network] POST " + url.origin + (this.mockEndpoint ? "" : url.pathname));
152
+ console.log("Avo Inspector: [network] Request body (" + data.length + " bytes" +
153
+ (compressed ? ", gzip" : "") + ")");
56
154
  }
57
- const req = (0, https_1.request)(options, (res) => {
58
- if (AvoInspector_1.AvoInspector.shouldLog) {
59
- console.log("Avo Inspector: [network] Response status: " + res.statusCode + " " + res.statusMessage);
155
+ let settled = false;
156
+ const finish = (fn) => {
157
+ if (settled) {
158
+ return;
60
159
  }
61
- const chunks = [];
62
- res.on("data", (data) => chunks.push(data));
63
- res.on("end", () => {
64
- if (res.statusCode === 200) {
65
- try {
66
- const responseBody = Buffer.concat(chunks).toString();
67
- const data = JSON.parse(responseBody);
68
- if (typeof data.samplingRate === "number" && data.samplingRate >= 0 && data.samplingRate <= 1) {
69
- this.samplingRate = data.samplingRate;
160
+ settled = true;
161
+ clearTimeout(timer);
162
+ this.inFlightRequests.delete(req);
163
+ fn();
164
+ };
165
+ let req;
166
+ try {
167
+ req = send(url, { method: "POST", headers }, (res) => {
168
+ if (AvoInspector_1.AvoInspector.shouldLog) {
169
+ console.log("Avo Inspector: [network] Response status: " + res.statusCode + " " + res.statusMessage);
170
+ }
171
+ const chunks = [];
172
+ res.on("data", (chunk) => chunks.push(chunk));
173
+ res.on("end", () => {
174
+ if (res.statusCode === 200) {
175
+ try {
176
+ const responseBody = Buffer.concat(chunks).toString();
177
+ const body = JSON.parse(responseBody);
178
+ if (body && typeof body.samplingRate === "number" && body.samplingRate >= 0 && body.samplingRate <= 1) {
179
+ this.samplingRate = body.samplingRate;
180
+ }
70
181
  }
182
+ catch (e) {
183
+ if (AvoInspector_1.AvoInspector.shouldLog) {
184
+ console.warn("Avo Inspector: [network] Failed to parse response JSON: " + e);
185
+ }
186
+ }
187
+ }
188
+ else if (AvoInspector_1.AvoInspector.shouldLog) {
189
+ console.warn("Avo Inspector: [network] Non-200 response: " + res.statusCode);
71
190
  }
72
- catch (e) {
73
- if (AvoInspector_1.AvoInspector.shouldLog) {
74
- console.warn("Avo Inspector: [network] Failed to parse response JSON: " + e);
191
+ finish(() => resolve(res.statusCode));
192
+ });
193
+ // A response cut off mid-body emits neither "end" nor a request "error". Its status
194
+ // already arrived and decides, as for an unparseable body: a 200 is delivered (the
195
+ // sampling rate is unchanged), anything else a non-200.
196
+ const truncated = () => {
197
+ if (!res.complete) {
198
+ if (AvoInspector_1.AvoInspector.shouldLog && !settled) {
199
+ console.warn("Avo Inspector: [network] Response ended before its body was complete");
75
200
  }
201
+ finish(() => resolve(res.statusCode));
76
202
  }
77
- }
78
- else if (AvoInspector_1.AvoInspector.shouldLog) {
79
- console.warn("Avo Inspector: [network] Non-200 response: " + res.statusCode);
80
- }
81
- resolve();
203
+ };
204
+ res.on("aborted", truncated);
205
+ res.on("error", truncated);
206
+ res.on("close", truncated);
82
207
  });
83
- });
84
- req.write(data);
85
- req.setTimeout(10000);
86
- req.on("error", (err) => {
208
+ }
209
+ catch (e) {
210
+ // A synchronous throw (for example invalid request options) is a failed send, with
211
+ // the documented reason; nothing was started, so there is nothing to clean up.
87
212
  if (AvoInspector_1.AvoInspector.shouldLog) {
88
- console.error("Avo Inspector: [network] Request error: " + err);
213
+ console.error("Avo Inspector: [network] Request could not be started");
89
214
  }
90
215
  reject("Request failed");
91
- });
92
- req.on("timeout", () => {
216
+ return;
217
+ }
218
+ this.inFlightRequests.add(req);
219
+ // An in-flight send must not hold the process open: at exit the beforeExit drain
220
+ // keeps it alive, under one deadline, while it sends what is left.
221
+ req.on("socket", (socket) => socket.unref());
222
+ // A wall-clock budget for the whole request, not just socket idleness.
223
+ const timer = setTimeout(() => {
93
224
  if (AvoInspector_1.AvoInspector.shouldLog) {
94
225
  console.error("Avo Inspector: [network] Request timed out after 10s");
95
226
  }
227
+ finish(() => reject("Request timed out"));
96
228
  req.destroy();
97
- reject("Request timed out");
229
+ }, AvoNetworkCallsHandler.requestTimeoutMs);
230
+ timer.unref();
231
+ req.on("error", (err) => {
232
+ if (AvoInspector_1.AvoInspector.shouldLog && !settled) {
233
+ console.error("Avo Inspector: [network] Request error: " + err);
234
+ }
235
+ finish(() => reject("Request failed"));
98
236
  });
99
- req.end();
237
+ req.end(data);
100
238
  });
101
239
  }
102
- bodyForEventSchemaCall(anonymousId, eventName, eventProperties, eventId, eventHash, rawEventProperties) {
103
- let eventSchemaBody = this.createBaseCallBody(anonymousId);
240
+ /** Builds the wire body for one event, encrypting property values when configured. */
241
+ bodyForEventSchemaCall(anonymousId, eventName, eventProperties, eventId, eventHash, rawEventProperties, gatewayFields, stamp) {
242
+ let eventSchemaBody = this.createBaseCallBody(anonymousId, gatewayFields, stamp);
104
243
  eventSchemaBody.type = "event";
105
244
  eventSchemaBody.eventName = eventName;
106
245
  if (AvoEncryption_1.AvoEncryption.shouldEncrypt(this.envName, this.publicEncryptionKey) && rawEventProperties) {
@@ -109,51 +248,65 @@ class AvoNetworkCallsHandler {
109
248
  else {
110
249
  eventSchemaBody.eventProperties = eventProperties;
111
250
  }
112
- if (eventId != null) {
113
- eventSchemaBody.avoFunction = true;
114
- eventSchemaBody.eventId = eventId;
115
- eventSchemaBody.eventHash = eventHash;
116
- }
117
- else {
118
- eventSchemaBody.avoFunction = false;
119
- eventSchemaBody.eventId = null;
120
- eventSchemaBody.eventHash = null;
121
- }
251
+ AvoNetworkCallsHandler.applyAvoFunctionFields(eventSchemaBody, eventId, eventHash);
122
252
  return eventSchemaBody;
123
253
  }
254
+ /** Returns the event properties, with values encrypted when encryption applies. */
124
255
  buildEventProperties(eventProperties, rawEventProperties) {
125
256
  if (AvoEncryption_1.AvoEncryption.shouldEncrypt(this.envName, this.publicEncryptionKey) && rawEventProperties) {
126
257
  return this.encryptProperties(eventProperties, rawEventProperties);
127
258
  }
128
259
  return eventProperties;
129
260
  }
130
- bodyForValidatedEventSchemaCall(anonymousId, eventName, eventProperties, eventId, eventHash, eventSpecMetadata, propertyResults) {
131
- // Build a map of validation results by property name
261
+ /** Builds the wire body for one event, including its event-spec validation results. */
262
+ bodyForValidatedEventSchemaCall(anonymousId, eventName, eventProperties, eventId, eventHash, eventSpecMetadata, propertyResults, gatewayFields, stamp) {
132
263
  const validationMap = new Map();
133
264
  for (const result of propertyResults) {
134
265
  validationMap.set(result.propertyName, result);
135
266
  }
136
267
  // Merge validation results into eventProperties (matching Android)
137
- const mergedProperties = eventProperties.map((prop) => {
138
- const validation = validationMap.get(prop.propertyName);
139
- if (validation) {
140
- const merged = Object.assign({}, prop);
141
- if (validation.failedEventIds.length > 0) {
142
- merged.failedEventIds = validation.failedEventIds;
143
- }
144
- if (validation.passedEventIds.length > 0) {
145
- merged.passedEventIds = validation.passedEventIds;
146
- }
147
- return merged;
148
- }
149
- return prop;
150
- });
151
- let body = this.createBaseCallBody(anonymousId);
268
+ const mergedProperties = eventProperties.map((prop) => AvoNetworkCallsHandler.withValidation(prop, validationMap.get(prop.propertyName)));
269
+ let body = this.createBaseCallBody(anonymousId, gatewayFields, stamp);
152
270
  body.type = "event";
153
271
  body.eventName = eventName;
154
272
  body.eventProperties = mergedProperties;
155
- body.streamId = anonymousId;
156
273
  body.eventSpecMetadata = eventSpecMetadata;
274
+ AvoNetworkCallsHandler.applyAvoFunctionFields(body, eventId, eventHash);
275
+ return body;
276
+ }
277
+ /**
278
+ * A property with its failed event IDs, and those of its object properties merged into its
279
+ * children. A list's children are its element schemas; each object element gets the
280
+ * results of the properties it has (a list of objects is validated across all its items).
281
+ */
282
+ static withValidation(prop, outcome) {
283
+ if (outcome === undefined) {
284
+ return prop;
285
+ }
286
+ const merged = Object.assign({}, prop);
287
+ if (outcome.failedEventIds.length > 0) {
288
+ merged.failedEventIds = outcome.failedEventIds;
289
+ }
290
+ const childOutcomes = outcome.children;
291
+ if (childOutcomes !== undefined && Array.isArray(prop.children)) {
292
+ const mergeChild = (child) => {
293
+ if (Array.isArray(child)) {
294
+ return child.map(mergeChild);
295
+ }
296
+ if (child !== null && typeof child === "object" && typeof child.propertyName === "string") {
297
+ const childOutcome = Object.prototype.hasOwnProperty.call(childOutcomes, child.propertyName)
298
+ ? childOutcomes[child.propertyName]
299
+ : undefined;
300
+ return AvoNetworkCallsHandler.withValidation(child, childOutcome);
301
+ }
302
+ return child;
303
+ };
304
+ merged.children = prop.children.map(mergeChild);
305
+ }
306
+ return merged;
307
+ }
308
+ // An event with an eventId came from an Avo function (Codegen): it carries the id and hash.
309
+ static applyAvoFunctionFields(body, eventId, eventHash) {
157
310
  if (eventId != null) {
158
311
  body.avoFunction = true;
159
312
  body.eventId = eventId;
@@ -164,7 +317,6 @@ class AvoNetworkCallsHandler {
164
317
  body.eventId = null;
165
318
  body.eventHash = null;
166
319
  }
167
- return body;
168
320
  }
169
321
  encryptProperties(properties, rawEventProperties) {
170
322
  var _a;
@@ -175,7 +327,16 @@ class AvoNetworkCallsHandler {
175
327
  continue;
176
328
  }
177
329
  const rawValue = rawEventProperties[prop.propertyName];
178
- const jsonValue = (_a = JSON.stringify(rawValue)) !== null && _a !== void 0 ? _a : "null";
330
+ let jsonValue;
331
+ try {
332
+ jsonValue = (_a = JSON.stringify(rawValue)) !== null && _a !== void 0 ? _a : "null";
333
+ }
334
+ catch (e) {
335
+ // A value that cannot be serialized (e.g. cyclic) is omitted, like an encryption failure.
336
+ // Only the error's type is printed: toJSON, getters and proxies can put the value in it.
337
+ console.warn(`[Avo Inspector] Warning: could not serialize property "${prop.propertyName}" for encryption, omitting it. (${AvoLog_1.AvoLog.errorType(e)})`);
338
+ continue;
339
+ }
179
340
  const encrypted = AvoEncryption_1.AvoEncryption.encryptValue(jsonValue, this.publicEncryptionKey);
180
341
  if (encrypted === null) {
181
342
  // Encryption failure: omit the property (warning already logged by encryptValue)
@@ -185,21 +346,28 @@ class AvoNetworkCallsHandler {
185
346
  }
186
347
  return result;
187
348
  }
188
- createBaseCallBody(anonymousId) {
349
+ // `stamp` defaults to now and the current sampling rate.
350
+ createBaseCallBody(anonymousId, gatewayFields, stamp) {
189
351
  const body = {
190
352
  apiKey: this.apiKey,
191
353
  appName: this.appName,
192
- appVersion: this.appVersion,
354
+ appVersion: gatewayFields ? gatewayFields.appVersion : this.appVersion,
193
355
  libVersion: this.libVersion,
194
356
  env: this.envName,
195
- libPlatform: "node",
357
+ libPlatform: AvoInspectorVersion_1.LIB_PLATFORM,
196
358
  messageId: AvoGuid_1.AvoGuid.newGuid(),
197
- trackingId: "",
198
- sessionId: "",
359
+ streamId: anonymousId,
199
360
  anonymousId: anonymousId,
200
- createdAt: new Date().toISOString(),
201
- samplingRate: this.samplingRate,
361
+ createdAt: stamp ? stamp.createdAt : new Date().toISOString(),
362
+ samplingRate: stamp ? stamp.samplingRate : this.samplingRate,
202
363
  };
364
+ // Gateway coordinates are sent only when present; never as null or "".
365
+ if (gatewayFields && gatewayFields.outputReference !== undefined) {
366
+ body.outputReference = gatewayFields.outputReference;
367
+ }
368
+ if (gatewayFields && gatewayFields.originHint !== undefined) {
369
+ body.originHint = gatewayFields.originHint;
370
+ }
203
371
  if (this.publicEncryptionKey && this.publicEncryptionKey.length > 0) {
204
372
  body.publicEncryptionKey = this.publicEncryptionKey;
205
373
  }
@@ -207,4 +375,10 @@ class AvoNetworkCallsHandler {
207
375
  }
208
376
  }
209
377
  exports.AvoNetworkCallsHandler = AvoNetworkCallsHandler;
210
- AvoNetworkCallsHandler.trackingEndpoint = "/inspector/v1/track";
378
+ AvoNetworkCallsHandler.trackingEndpoint = "https://api.avo.app/inspector/v2/track";
379
+ AvoNetworkCallsHandler.mockEndpointEnvVar = "AVO_INSPECTOR_MOCK_ENDPOINT";
380
+ AvoNetworkCallsHandler.requestTimeoutMs = 10000;
381
+ AvoNetworkCallsHandler.gzipThresholdBytes = 1024;
382
+ // Warnings about the override print once per process, whatever the logging flag, on
383
+ // stderr (console.warn).
384
+ AvoNetworkCallsHandler.warnedMockEndpoint = null;
@@ -1,4 +1,9 @@
1
1
  export declare class AvoSchemaParser {
2
+ /**
3
+ * Maps each property to its name, type and (for objects and lists) child schema.
4
+ * Values nested deeper than MAX_DEPTH, cyclic references, and complex values past the
5
+ * MAX_EXPANSIONS budget are reported as "object".
6
+ */
2
7
  static extractSchema(eventProperties: {
3
8
  [propName: string]: any;
4
9
  }): Array<{
@@ -7,6 +12,7 @@ export declare class AvoSchemaParser {
7
12
  children?: any;
8
13
  }>;
9
14
  private static removeDuplicates;
15
+ private static childKey;
10
16
  private static getBasicPropType;
11
17
  private static getPropValueType;
12
18
  }