@aidenappleby/monitor-js 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -21,22 +21,91 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Monitor: () => Monitor,
24
- attachAxiosMonitor: () => attachAxiosMonitor
24
+ attachAxiosMonitor: () => attachAxiosMonitor,
25
+ isValidCorrelationId: () => isValidCorrelationId,
26
+ newJobId: () => newJobId,
27
+ newRequestId: () => newRequestId,
28
+ newTraceId: () => newTraceId
25
29
  });
26
30
  module.exports = __toCommonJS(index_exports);
27
31
 
32
+ // src/ids.ts
33
+ var CORRELATION_ID = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{8,64})$/;
34
+ function isValidCorrelationId(id) {
35
+ return id === "" || CORRELATION_ID.test(id);
36
+ }
37
+ function randomBytes(n) {
38
+ const out = new Uint8Array(n);
39
+ const c = globalThis.crypto;
40
+ if (c && typeof c.getRandomValues === "function") {
41
+ c.getRandomValues(out);
42
+ return out;
43
+ }
44
+ for (let i = 0; i < n; i++) out[i] = Math.floor(Math.random() * 256);
45
+ return out;
46
+ }
47
+ function hex(bytes) {
48
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
49
+ }
50
+ function newRequestId() {
51
+ return hex(randomBytes(8));
52
+ }
53
+ function newJobId() {
54
+ return hex(randomBytes(8));
55
+ }
56
+ function newTraceId() {
57
+ const b = randomBytes(16);
58
+ b[6] = b[6] & 15 | 64;
59
+ b[8] = b[8] & 63 | 128;
60
+ const h = hex(b);
61
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
62
+ }
63
+
28
64
  // src/client.ts
29
65
  var DEFAULT_FLUSH_INTERVAL = 2e3;
30
66
  var DEFAULT_BATCH_SIZE = 20;
31
67
  var MAX_QUEUE_SIZE = 500;
32
- var Monitor = class {
68
+ var MAX_LINE_BYTES = 1e6;
69
+ var MAX_FIELD_CHARS = 4096;
70
+ var KEEPALIVE_MAX_BYTES = 6e4;
71
+ var BASE_BACKOFF_MS = 1e3;
72
+ var MAX_BACKOFF_MS = 6e4;
73
+ var GROUPING_KEYS = ["error", "error_message", "message", "path", "uri", "method", "reason", "status_code"];
74
+ function classify(status) {
75
+ if (status >= 200 && status < 400) return "delivered";
76
+ if (status === 408 || status === 429) return "retryable";
77
+ if (status === 401 || status === 403 || status === 404 || status === 405) return "misconfigured";
78
+ if (status >= 400 && status < 500) return "rejected";
79
+ return "retryable";
80
+ }
81
+ function bisectBudget(n) {
82
+ let depth = 0;
83
+ for (let x = n; x > 1; x = Math.ceil(x / 2)) depth++;
84
+ return 4 * depth + 4;
85
+ }
86
+ var encoder;
87
+ function byteLength(s) {
88
+ if (typeof TextEncoder === "undefined") return s.length * 3;
89
+ encoder ??= new TextEncoder();
90
+ return encoder.encode(s).length;
91
+ }
92
+ function normalizeLevel(level) {
93
+ const l = (level || "info").toLowerCase();
94
+ return l === "warning" ? "warn" : l;
95
+ }
96
+ var Monitor = class _Monitor {
33
97
  config;
34
98
  ignoreErrors = [];
99
+ onDrop;
35
100
  queue = [];
36
101
  timer = null;
37
102
  userId = "";
38
- jobId = "";
103
+ jobId;
39
104
  active = false;
105
+ backoffUntil = 0;
106
+ failures = 0;
107
+ warnedMisconfigured = false;
108
+ counters = { enqueued: 0, flushed: 0, dropped: 0, quarantined: 0 };
40
109
  constructor(config) {
41
110
  this.config = {
42
111
  service: config.service,
@@ -48,6 +117,8 @@ var Monitor = class {
48
117
  debug: config.debug ?? false
49
118
  };
50
119
  this.ignoreErrors = config.ignoreErrors ?? [];
120
+ this.onDrop = config.onDrop;
121
+ this.jobId = newJobId();
51
122
  this.start();
52
123
  if (config.captureErrors !== false) {
53
124
  this.installErrorHandler();
@@ -64,29 +135,47 @@ var Monitor = class {
64
135
  clearUser() {
65
136
  this.userId = "";
66
137
  }
67
- /** Set a persistent job ID (session-level identifier) */
138
+ /**
139
+ * Set a persistent job ID (session-level identifier). It must be a UUID or
140
+ * 8-64 hex characters — see `isValidCorrelationId`; anything else is
141
+ * cleared from each event and kept in data.invalid_job_id.
142
+ */
68
143
  setJobId(jobId) {
69
144
  this.jobId = jobId;
70
145
  }
71
146
  /** Emit an event at a specific level */
72
147
  emit(name, level, opts) {
73
148
  if (!this.active) return;
149
+ let data = opts?.data ?? {};
150
+ const repair = (field, value) => {
151
+ if (isValidCorrelationId(value)) return value;
152
+ data = { ...data, [`invalid_${field}`]: value.slice(0, 128) };
153
+ if (this.config.debug) {
154
+ console.warn(`[monitor] cleared invalid ${field} ${JSON.stringify(value)} (monitor-core accepts a UUID or 8-64 hex characters)`);
155
+ }
156
+ return "";
157
+ };
158
+ const jobId = repair("job_id", this.jobId);
159
+ const requestId = repair("request_id", opts?.requestId ?? "");
160
+ const traceId = repair("trace_id", opts?.traceId ?? "");
74
161
  const event = {
75
162
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
76
163
  service: this.config.service,
77
164
  env: this.config.env,
78
- job_id: this.jobId,
79
- request_id: opts?.requestId ?? "",
80
- trace_id: opts?.traceId ?? "",
165
+ job_id: jobId,
166
+ request_id: requestId,
167
+ trace_id: traceId,
81
168
  user_id: opts?.userId ?? this.userId,
82
- name,
83
- level,
84
- data: opts?.data ?? {}
169
+ name: name || "event.unnamed",
170
+ level: normalizeLevel(level),
171
+ data
85
172
  };
86
173
  if (this.queue.length >= MAX_QUEUE_SIZE) {
87
174
  this.queue.shift();
175
+ this.recordDrop(1);
88
176
  }
89
177
  this.queue.push(event);
178
+ this.counters.enqueued++;
90
179
  if (this.config.debug) {
91
180
  console.debug(`[monitor] ${level} ${name}`, opts?.data);
92
181
  }
@@ -114,28 +203,17 @@ var Monitor = class {
114
203
  fatal(name, opts) {
115
204
  this.emit(name, "fatal", opts);
116
205
  }
206
+ /**
207
+ * Lifetime counters. Surface them wherever loss would otherwise go
208
+ * unnoticed: the system that would report dropped telemetry is the one
209
+ * dropping it.
210
+ */
211
+ stats() {
212
+ return { ...this.counters, queued: this.queue.length };
213
+ }
117
214
  /** Flush all queued events to the ingest endpoint */
118
215
  flush() {
119
- if (this.queue.length === 0) return;
120
- const batch = this.queue.splice(0);
121
- const payload = batch.map((e) => JSON.stringify(e)).join("\n");
122
- if (typeof fetch === "undefined") return;
123
- fetch(this.config.ingestUrl, {
124
- method: "POST",
125
- headers: {
126
- "Content-Type": "application/x-ndjson",
127
- "X-Api-Key": this.config.apiKey
128
- },
129
- body: payload,
130
- keepalive: true
131
- }).catch((err) => {
132
- if (this.config.debug) {
133
- console.warn("[monitor] flush failed:", err);
134
- }
135
- if (this.queue.length + batch.length <= MAX_QUEUE_SIZE) {
136
- this.queue = batch.concat(this.queue);
137
- }
138
- });
216
+ this.flushQueue(false);
139
217
  }
140
218
  /** Stop the monitor and flush remaining events */
141
219
  shutdown() {
@@ -143,14 +221,144 @@ var Monitor = class {
143
221
  clearInterval(this.timer);
144
222
  this.timer = null;
145
223
  }
146
- this.flush();
224
+ this.flushQueue(true);
147
225
  this.removeListeners();
148
226
  this.active = false;
149
227
  }
228
+ /**
229
+ * @param unloading the page (or process) is going away: ignore the backoff,
230
+ * since this is the last chance these events get.
231
+ */
232
+ flushQueue(unloading) {
233
+ if (this.queue.length === 0) return;
234
+ if (typeof fetch === "undefined") return;
235
+ if (!unloading && Date.now() < this.backoffUntil) return;
236
+ const batch = this.queue.splice(0);
237
+ this.send(batch, { remaining: bisectBudget(batch.length) });
238
+ }
239
+ send(events, budget) {
240
+ const lines = [];
241
+ const sent = [];
242
+ for (const e of events) {
243
+ const line = this.serialize(e);
244
+ if (line === null) {
245
+ this.recordDrop(1);
246
+ continue;
247
+ }
248
+ lines.push(line);
249
+ sent.push(e);
250
+ }
251
+ if (lines.length === 0) return;
252
+ const body = lines.join("\n");
253
+ let request;
254
+ try {
255
+ request = fetch(this.config.ingestUrl, {
256
+ method: "POST",
257
+ headers: {
258
+ "Content-Type": "application/x-ndjson",
259
+ "X-Api-Key": this.config.apiKey
260
+ },
261
+ body,
262
+ keepalive: byteLength(body) <= KEEPALIVE_MAX_BYTES
263
+ });
264
+ } catch (err) {
265
+ request = Promise.reject(err);
266
+ }
267
+ request.then(
268
+ (res) => this.handleResponse(res, sent, budget),
269
+ (err) => {
270
+ if (this.config.debug) {
271
+ console.warn("[monitor] flush failed:", err);
272
+ }
273
+ this.retryLater(sent);
274
+ }
275
+ );
276
+ }
277
+ handleResponse(res, events, budget) {
278
+ const status = typeof res?.status === "number" ? res.status : 0;
279
+ const outcome = res?.ok ? "delivered" : classify(status);
280
+ switch (outcome) {
281
+ case "delivered":
282
+ this.counters.flushed += events.length;
283
+ this.failures = 0;
284
+ this.backoffUntil = 0;
285
+ return;
286
+ case "rejected":
287
+ if (events.length > 1 && budget.remaining > 0) {
288
+ budget.remaining--;
289
+ const mid = events.length >> 1;
290
+ this.send(events.slice(0, mid), budget);
291
+ this.send(events.slice(mid), budget);
292
+ return;
293
+ }
294
+ this.counters.quarantined += events.length;
295
+ this.recordDrop(events.length);
296
+ if (this.config.debug) {
297
+ console.warn(`[monitor] ingest rejected ${events.length} event(s) as malformed (status ${status}):`, events.map((e) => e.name));
298
+ }
299
+ return;
300
+ case "misconfigured":
301
+ this.recordDrop(events.length);
302
+ if (!this.warnedMisconfigured) {
303
+ this.warnedMisconfigured = true;
304
+ console.warn(`[monitor] ingest refused events with status ${status} \u2014 check ingestUrl and apiKey. Events are being dropped.`);
305
+ }
306
+ return;
307
+ default:
308
+ this.retryLater(events);
309
+ }
310
+ }
311
+ /** Put events back at the front of the queue and back off before retrying. */
312
+ retryLater(events) {
313
+ this.failures++;
314
+ const ceiling = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.min(this.failures - 1, 16));
315
+ this.backoffUntil = Date.now() + 50 + Math.random() * ceiling;
316
+ const room = MAX_QUEUE_SIZE - this.queue.length;
317
+ const keep = room <= 0 ? [] : events.length > room ? events.slice(events.length - room) : events;
318
+ this.recordDrop(events.length - keep.length);
319
+ if (keep.length > 0) {
320
+ this.queue = keep.concat(this.queue);
321
+ }
322
+ }
323
+ /**
324
+ * One NDJSON line for e, or null if it cannot be serialized. Never throws:
325
+ * flush runs inside emit's auto-flush, and emit must never throw into the
326
+ * caller.
327
+ */
328
+ serialize(e) {
329
+ try {
330
+ const line = JSON.stringify(e);
331
+ if (line.length <= MAX_LINE_BYTES / 3) return line;
332
+ const size = byteLength(line);
333
+ if (size <= MAX_LINE_BYTES) return line;
334
+ const kept = { truncated: true, original_size_bytes: size };
335
+ for (const k of GROUPING_KEYS) {
336
+ const v = e.data[k];
337
+ if (typeof v === "string") kept[k] = v.slice(0, MAX_FIELD_CHARS);
338
+ else if (typeof v === "number" || typeof v === "boolean") kept[k] = v;
339
+ }
340
+ const shrunk = JSON.stringify({ ...e, data: kept });
341
+ return byteLength(shrunk) <= MAX_LINE_BYTES ? shrunk : null;
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
346
+ recordDrop(n) {
347
+ if (n <= 0) return;
348
+ this.counters.dropped += n;
349
+ if (this.onDrop) {
350
+ try {
351
+ this.onDrop(this.counters.dropped);
352
+ } catch {
353
+ }
354
+ }
355
+ }
150
356
  start() {
151
357
  if (this.active) return;
152
358
  this.active = true;
153
- this.timer = setInterval(() => this.flush(), this.config.flushInterval);
359
+ const t = setInterval(() => this.flush(), this.config.flushInterval);
360
+ if (typeof t.unref === "function") t.unref();
361
+ this.timer = t;
154
362
  if (typeof document !== "undefined") {
155
363
  document.addEventListener("visibilitychange", this.handleVisibilityChange);
156
364
  }
@@ -160,11 +368,11 @@ var Monitor = class {
160
368
  }
161
369
  handleVisibilityChange = () => {
162
370
  if (document.visibilityState === "hidden") {
163
- this.flush();
371
+ this.flushQueue(true);
164
372
  }
165
373
  };
166
374
  handlePageHide = () => {
167
- this.flush();
375
+ this.flushQueue(true);
168
376
  };
169
377
  shouldIgnoreError(message, stack) {
170
378
  if (this.ignoreErrors.length === 0) return false;
@@ -181,6 +389,20 @@ var Monitor = class {
181
389
  }
182
390
  return false;
183
391
  }
392
+ /**
393
+ * The route a browser error happened on.
394
+ *
395
+ * Deliberately `pathname` only — never the search string or hash. Query
396
+ * parameters routinely carry tokens, emails and other personal data, and this
397
+ * value is both stored on the event and folded into the server-side issue
398
+ * fingerprint, so anything included here is retained and grouped on.
399
+ *
400
+ * Returns undefined outside a browser so the Node handlers stay unaffected.
401
+ */
402
+ currentPath() {
403
+ if (typeof window === "undefined" || !window.location) return void 0;
404
+ return window.location.pathname;
405
+ }
184
406
  errorHandler = (event) => {
185
407
  const stack = event.error?.stack;
186
408
  if (this.shouldIgnoreError(event.message ?? "", stack)) return;
@@ -190,7 +412,8 @@ var Monitor = class {
190
412
  filename: event.filename,
191
413
  lineno: event.lineno,
192
414
  colno: event.colno,
193
- stack
415
+ stack,
416
+ path: this.currentPath()
194
417
  }
195
418
  });
196
419
  };
@@ -202,18 +425,90 @@ var Monitor = class {
202
425
  this.emit("client.error.unhandled_rejection", "error", {
203
426
  data: {
204
427
  message,
205
- stack
428
+ stack,
429
+ path: this.currentPath()
206
430
  }
207
431
  });
208
432
  };
433
+ // --- Node process handlers ---
434
+ // Adding an uncaughtException or unhandledRejection listener changes what Node
435
+ // does. With no listener, either one prints the error and exits with code 1;
436
+ // with any listener, Node assumes it was handled and keeps running — in
437
+ // whatever state the failure left it. So when this SDK is the only listener,
438
+ // it reports the error and then does what Node would have done. When the app
439
+ // has a listener of its own, the app has already decided; the SDK only reports.
440
+ /** Grace for the final batch to leave before a Node-style crash exits. */
441
+ static NODE_CRASH_GRACE_MS = 1500;
442
+ /** Rejections already reported, so the re-raise below is not reported twice. */
443
+ reportedRejections = /* @__PURE__ */ new WeakSet();
444
+ nodeExceptionHandler = (err) => {
445
+ const alreadyReported = typeof err === "object" && err !== null && this.reportedRejections.has(err);
446
+ const e = err;
447
+ const message = e?.message ?? String(err);
448
+ const stack = e?.stack;
449
+ if (!alreadyReported && !this.shouldIgnoreError(message, stack)) {
450
+ this.emit("client.error.uncaught", "error", {
451
+ data: {
452
+ message,
453
+ stack
454
+ }
455
+ });
456
+ }
457
+ if (this.isSoleListener("uncaughtException")) {
458
+ this.crashLikeNode(err);
459
+ }
460
+ };
461
+ nodeRejectionHandler = (reason) => {
462
+ const r = reason;
463
+ const message = r?.message ?? String(reason);
464
+ const stack = r?.stack;
465
+ if (!this.shouldIgnoreError(message, stack)) {
466
+ this.emit("client.error.unhandled_rejection", "error", {
467
+ data: {
468
+ message,
469
+ stack
470
+ }
471
+ });
472
+ }
473
+ if (this.isSoleListener("unhandledRejection")) {
474
+ if (typeof reason === "object" && reason !== null) {
475
+ this.reportedRejections.add(reason);
476
+ }
477
+ this.reraise(reason);
478
+ }
479
+ };
480
+ /** Hand an unhandled rejection back to Node as an uncaught exception. */
481
+ reraise(reason) {
482
+ process?.nextTick?.(() => {
483
+ throw reason;
484
+ });
485
+ }
486
+ /** True when this instance's own handler is the only listener for the event. */
487
+ isSoleListener(event) {
488
+ const count = typeof process === "undefined" ? void 0 : process.listenerCount;
489
+ if (typeof count !== "function") {
490
+ return false;
491
+ }
492
+ return count.call(process, event) <= 1;
493
+ }
494
+ /** Print the error as Node would, give the batch a moment to leave, exit 1. */
495
+ crashLikeNode(err) {
496
+ console.error(err);
497
+ this.flush();
498
+ setTimeout(() => process?.exit?.(1), _Monitor.NODE_CRASH_GRACE_MS);
499
+ }
209
500
  installErrorHandler() {
210
501
  if (typeof window !== "undefined") {
211
502
  window.addEventListener("error", this.errorHandler);
503
+ } else if (typeof process !== "undefined" && typeof process.on === "function") {
504
+ process.on("uncaughtException", this.nodeExceptionHandler);
212
505
  }
213
506
  }
214
507
  installRejectionHandler() {
215
508
  if (typeof window !== "undefined") {
216
509
  window.addEventListener("unhandledrejection", this.rejectionHandler);
510
+ } else if (typeof process !== "undefined" && typeof process.on === "function") {
511
+ process.on("unhandledRejection", this.nodeRejectionHandler);
217
512
  }
218
513
  }
219
514
  removeListeners() {
@@ -225,10 +520,18 @@ var Monitor = class {
225
520
  if (typeof document !== "undefined") {
226
521
  document.removeEventListener("visibilitychange", this.handleVisibilityChange);
227
522
  }
523
+ if (typeof process !== "undefined" && typeof process.removeListener === "function") {
524
+ process.removeListener("uncaughtException", this.nodeExceptionHandler);
525
+ process.removeListener("unhandledRejection", this.nodeRejectionHandler);
526
+ }
228
527
  }
229
528
  };
230
529
 
231
530
  // src/axios.ts
531
+ function stripQuery(url) {
532
+ const i = url.search(/[?#]/);
533
+ return i === -1 ? url : url.slice(0, i);
534
+ }
232
535
  function attachAxiosMonitor(axiosInstance, monitor, opts) {
233
536
  const minStatus = opts?.minStatus ?? 400;
234
537
  const reportSuccess = opts?.reportSuccess ?? false;
@@ -239,7 +542,7 @@ function attachAxiosMonitor(axiosInstance, monitor, opts) {
239
542
  });
240
543
  axiosInstance.interceptors.response.use(
241
544
  (response) => {
242
- const url = response.config?.url ?? "";
545
+ const url = stripQuery(response.config?.url ?? "");
243
546
  if (ignorePaths.some((p) => url.includes(p))) return response;
244
547
  const statusCode = response.status ?? 0;
245
548
  const requestId = response.headers?.["x-request-id"] ?? "";
@@ -274,7 +577,7 @@ function attachAxiosMonitor(axiosInstance, monitor, opts) {
274
577
  return response;
275
578
  },
276
579
  (error) => {
277
- const url = error.config?.url ?? "";
580
+ const url = stripQuery(error.config?.url ?? "");
278
581
  if (ignorePaths.some((p) => url.includes(p))) {
279
582
  return Promise.reject(error);
280
583
  }
@@ -315,6 +618,10 @@ function attachAxiosMonitor(axiosInstance, monitor, opts) {
315
618
  // Annotate the CommonJS export names for ESM import in node:
316
619
  0 && (module.exports = {
317
620
  Monitor,
318
- attachAxiosMonitor
621
+ attachAxiosMonitor,
622
+ isValidCorrelationId,
623
+ newJobId,
624
+ newRequestId,
625
+ newTraceId
319
626
  });
320
627
  //# sourceMappingURL=index.js.map