@futurewindai/wotchi 0.1.0-beta.5 → 0.1.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +78 -24
  2. package/SECURITY.md +2 -2
  3. package/dist/cjs/core/admission.js +22 -0
  4. package/dist/cjs/core/client.js +46 -0
  5. package/dist/cjs/core/config.js +16 -0
  6. package/dist/cjs/core/diagnostics.js +4 -0
  7. package/dist/cjs/core/group-store.js +7 -8
  8. package/dist/cjs/core/limits.js +5 -1
  9. package/dist/cjs/core/normalize.js +6 -1
  10. package/dist/cjs/core/notification-queue.js +77 -8
  11. package/dist/cjs/core/prometheus.js +129 -0
  12. package/dist/cjs/core/redact.js +4 -1
  13. package/dist/cjs/core/runtime-monitor.js +109 -0
  14. package/dist/cjs/index.js +6 -1
  15. package/dist/cjs/integrations/express/error-handler.js +21 -5
  16. package/dist/cjs/integrations/nest/capture.js +21 -0
  17. package/dist/cjs/integrations/nest/exception-filter.js +62 -14
  18. package/dist/cjs/integrations/nest/filter-wrapper.js +17 -0
  19. package/dist/cjs/integrations/nest/index.js +6 -1
  20. package/dist/cjs/integrations/nest/module.js +44 -0
  21. package/dist/cjs/integrations/nest/register.js +15 -1
  22. package/dist/esm/core/admission.js +19 -0
  23. package/dist/esm/core/client.js +46 -0
  24. package/dist/esm/core/config.js +17 -1
  25. package/dist/esm/core/diagnostics.js +4 -0
  26. package/dist/esm/core/group-store.js +7 -8
  27. package/dist/esm/core/limits.js +4 -0
  28. package/dist/esm/core/normalize.js +6 -1
  29. package/dist/esm/core/notification-queue.js +78 -9
  30. package/dist/esm/core/prometheus.js +125 -0
  31. package/dist/esm/core/redact.js +4 -1
  32. package/dist/esm/core/runtime-monitor.js +106 -0
  33. package/dist/esm/index.js +3 -1
  34. package/dist/esm/integrations/express/error-handler.js +21 -5
  35. package/dist/esm/integrations/nest/capture.js +17 -0
  36. package/dist/esm/integrations/nest/exception-filter.js +63 -15
  37. package/dist/esm/integrations/nest/filter-wrapper.js +14 -0
  38. package/dist/esm/integrations/nest/index.js +2 -0
  39. package/dist/esm/integrations/nest/module.js +41 -0
  40. package/dist/esm/integrations/nest/register.js +15 -1
  41. package/dist/types/core/admission.d.ts +10 -0
  42. package/dist/types/core/config.d.ts +10 -0
  43. package/dist/types/core/diagnostics.d.ts +3 -1
  44. package/dist/types/core/incident-policy.d.ts +1 -1
  45. package/dist/types/core/limits.d.ts +4 -0
  46. package/dist/types/core/notification-queue.d.ts +9 -0
  47. package/dist/types/core/prometheus.d.ts +7 -0
  48. package/dist/types/core/runtime-monitor.d.ts +15 -0
  49. package/dist/types/core/types.d.ts +16 -1
  50. package/dist/types/index.d.ts +5 -1
  51. package/dist/types/integrations/nest/capture.d.ts +4 -0
  52. package/dist/types/integrations/nest/exception-filter.d.ts +6 -2
  53. package/dist/types/integrations/nest/filter-wrapper.d.ts +7 -0
  54. package/dist/types/integrations/nest/index.d.ts +4 -0
  55. package/dist/types/integrations/nest/module.d.ts +9 -0
  56. package/dist/types/integrations/nest/register.d.ts +3 -0
  57. package/dist/types-cjs/core/admission.d.cts +10 -0
  58. package/dist/types-cjs/core/config.d.cts +10 -0
  59. package/dist/types-cjs/core/diagnostics.d.cts +3 -1
  60. package/dist/types-cjs/core/incident-policy.d.cts +1 -1
  61. package/dist/types-cjs/core/limits.d.cts +4 -0
  62. package/dist/types-cjs/core/notification-queue.d.cts +9 -0
  63. package/dist/types-cjs/core/prometheus.d.cts +7 -0
  64. package/dist/types-cjs/core/runtime-monitor.d.cts +15 -0
  65. package/dist/types-cjs/core/types.d.cts +16 -1
  66. package/dist/types-cjs/index.d.cts +5 -1
  67. package/dist/types-cjs/integrations/nest/capture.d.cts +4 -0
  68. package/dist/types-cjs/integrations/nest/exception-filter.d.cts +6 -2
  69. package/dist/types-cjs/integrations/nest/filter-wrapper.d.cts +7 -0
  70. package/dist/types-cjs/integrations/nest/index.d.cts +4 -0
  71. package/dist/types-cjs/integrations/nest/module.d.cts +9 -0
  72. package/dist/types-cjs/integrations/nest/register.d.cts +3 -0
  73. package/package.json +12 -1
@@ -1,4 +1,5 @@
1
1
  import { validateConfig } from "./config.js";
2
+ import { createCaptureAdmission } from "./admission.js";
2
3
  import { createDiagnosticsState, snapshotDiagnostics } from "./diagnostics.js";
3
4
  import { fingerprintSafeErrorEvent } from "./fingerprint.js";
4
5
  import { createGroupStore } from "./group-store.js";
@@ -279,9 +280,19 @@ export function createWotchi(config) {
279
280
  const queue = createNotificationQueue({
280
281
  maxPendingAlerts: normalized.queue.maxPendingAlerts,
281
282
  concurrency: normalized.queue.concurrency,
283
+ notifierTimeoutMs: normalized.queue.notifierTimeoutMs,
284
+ notifierCircuitBreaker: normalized.queue.notifierCircuitBreaker,
282
285
  });
286
+ const admission = normalized.overload === undefined
287
+ ? undefined
288
+ : createCaptureAdmission({
289
+ maxEventsPerSecond: normalized.overload.maxEventsPerSecond,
290
+ burst: normalized.overload.burst,
291
+ now,
292
+ });
283
293
  const privacy = normalized.privacy;
284
294
  let eventSequence = 0;
295
+ let overloadLastAlertAt = 0;
285
296
  const matchingRule = (event) => normalized.rules.find((rule) => (rule.environment === undefined || rule.environment === event.environment) &&
286
297
  (rule.route === undefined || rule.route === event.request?.route));
287
298
  const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold, fingerprintOverride, severityOverride, trace, correlationId, operation, job, tags) => {
@@ -402,11 +413,28 @@ export function createWotchi(config) {
402
413
  }
403
414
  diagnostics.capturedEvents += 1;
404
415
  };
416
+ const emitOverloadSignal = (timestamp) => {
417
+ const cooldownMs = normalized.overload?.alertCooldownMs ?? 0;
418
+ if (cooldownMs > 0 && timestamp - overloadLastAlertAt < cooldownMs) {
419
+ return;
420
+ }
421
+ overloadLastAlertAt = timestamp;
422
+ captureSafeEvent(new Error("Wotchi capture overload: admission limit reached"), undefined, { droppedEvents: diagnostics.eventsDroppedOverload }, undefined, "runtime-monitor", 1, "wotchi.capture.overload", "high", undefined, undefined, "wotchi.overload", undefined, undefined);
423
+ };
405
424
  const captureException = (error, context, options) => {
406
425
  if (!normalized.enabled) {
407
426
  return;
408
427
  }
428
+ if (queue.isClosed()) {
429
+ diagnostics.capturesAfterShutdown += 1;
430
+ return;
431
+ }
409
432
  try {
433
+ if (admission !== undefined && !admission.tryAcquire()) {
434
+ diagnostics.eventsDroppedOverload += 1;
435
+ emitOverloadSignal(now());
436
+ return;
437
+ }
410
438
  captureSafeEvent(error, undefined, context, options?.request, "error", normalizeEventAlertThreshold(options?.alertThreshold) ?? normalized.grouping.alertThreshold, options?.fingerprint, options?.severity, options?.trace, options?.correlationId, options?.operation, options?.job, options?.tags);
411
439
  }
412
440
  catch {
@@ -417,6 +445,10 @@ export function createWotchi(config) {
417
445
  if (!normalized.enabled) {
418
446
  return;
419
447
  }
448
+ if (queue.isClosed()) {
449
+ diagnostics.capturesAfterShutdown += 1;
450
+ return;
451
+ }
420
452
  try {
421
453
  if (event === null || typeof event !== "object" || event.level !== "error") {
422
454
  throw new TypeError("event.level must be error");
@@ -425,6 +457,14 @@ export function createWotchi(config) {
425
457
  throw new TypeError("event.message must be a string");
426
458
  }
427
459
  const alertThreshold = normalizeEventAlertThreshold(event.alertThreshold);
460
+ if (admission !== undefined &&
461
+ event.kind !== "process-monitor" &&
462
+ event.kind !== "runtime-monitor" &&
463
+ !admission.tryAcquire()) {
464
+ diagnostics.eventsDroppedOverload += 1;
465
+ emitOverloadSignal(now());
466
+ return;
467
+ }
428
468
  captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error", alertThreshold ?? normalized.grouping.alertThreshold, event.fingerprint, event.severity, event.trace, event.correlationId, event.operation, event.job, event.tags);
429
469
  }
430
470
  catch {
@@ -437,6 +477,8 @@ export function createWotchi(config) {
437
477
  alertsDropped: queue.alertsDropped(),
438
478
  alertsSent: queue.alertsSent(),
439
479
  notifierFailures: queue.notifierFailures(),
480
+ notifierTimeouts: queue.notifierTimeouts(),
481
+ notifierCircuitOpenSkips: queue.notifierCircuitOpenSkips(),
440
482
  activeGroups: groupStore.size(),
441
483
  pendingAlerts: queue.pending(),
442
484
  });
@@ -500,11 +542,15 @@ export function createWotchi(config) {
500
542
  ...(status === "notifier-failed" ? { error: "One or more notifiers failed." } : {}),
501
543
  };
502
544
  };
545
+ const shutdown = async (timeoutMs) => {
546
+ await queue.close(timeoutMs);
547
+ };
503
548
  return {
504
549
  captureException,
505
550
  captureEvent,
506
551
  testAlert,
507
552
  flush: (timeoutMs) => queue.flush(timeoutMs),
553
+ shutdown,
508
554
  getDiagnostics: readDiagnostics,
509
555
  };
510
556
  }
@@ -1,5 +1,5 @@
1
1
  import { WotchiConfigurationError } from "./errors.js";
2
- import { MAX_ALERT_THRESHOLD, MAX_COOLDOWN_MS, MAX_EVENTS_PER_WINDOW, MAX_GROUPS, MAX_NORMALIZATION_DEPTH, MAX_NORMALIZATION_KEYS, MAX_NORMALIZATION_STACK_LENGTH, MAX_NORMALIZATION_STRING_LENGTH, MAX_PENDING_ALERTS, MAX_WINDOW_MS, } from "./limits.js";
2
+ import { MAX_ALERT_THRESHOLD, MAX_CAPTURE_RATE, MAX_CIRCUIT_BREAKER_COOLDOWN_MS, MAX_CIRCUIT_BREAKER_FAILURES, MAX_COOLDOWN_MS, MAX_EVENTS_PER_WINDOW, MAX_GROUPS, MAX_NORMALIZATION_DEPTH, MAX_NORMALIZATION_KEYS, MAX_NORMALIZATION_STACK_LENGTH, MAX_NORMALIZATION_STRING_LENGTH, MAX_PENDING_ALERTS, MAX_NOTIFIER_TIMEOUT_MS, MAX_WINDOW_MS, } from "./limits.js";
3
3
  export { WotchiConfigurationError };
4
4
  const MAX_REDACT_KEYS = 100;
5
5
  const MAX_RULES = 50;
@@ -228,7 +228,9 @@ export function validateConfig(config) {
228
228
  }
229
229
  const grouping = readOptionalRecord(config.grouping, "grouping");
230
230
  const queue = readOptionalRecord(config.queue, "queue");
231
+ const notifierCircuitBreaker = readOptionalRecord(queue?.notifierCircuitBreaker, "queue.notifierCircuitBreaker");
231
232
  const privacy = readOptionalRecord(config.privacy, "privacy");
233
+ const overload = readOptionalRecord(config.overload, "overload");
232
234
  const links = normalizeLinks(config.links);
233
235
  const concurrency = queue?.concurrency;
234
236
  if (concurrency !== undefined && concurrency !== 1) {
@@ -255,7 +257,21 @@ export function validateConfig(config) {
255
257
  queue: Object.freeze({
256
258
  maxPendingAlerts: readPositiveInteger(queue, "maxPendingAlerts", 100, MAX_PENDING_ALERTS),
257
259
  concurrency: 1,
260
+ notifierTimeoutMs: readPositiveInteger(queue, "notifierTimeoutMs", 5_000, MAX_NOTIFIER_TIMEOUT_MS),
261
+ notifierCircuitBreaker: Object.freeze({
262
+ failureThreshold: readPositiveInteger(notifierCircuitBreaker, "failureThreshold", 3, MAX_CIRCUIT_BREAKER_FAILURES),
263
+ cooldownMs: readPositiveInteger(notifierCircuitBreaker, "cooldownMs", 30_000, MAX_CIRCUIT_BREAKER_COOLDOWN_MS),
264
+ }),
258
265
  }),
266
+ ...(overload === undefined
267
+ ? {}
268
+ : {
269
+ overload: Object.freeze({
270
+ maxEventsPerSecond: readPositiveInteger(overload, "maxEventsPerSecond", 1_000, MAX_CAPTURE_RATE),
271
+ burst: readPositiveInteger(overload, "burst", readPositiveInteger(overload, "maxEventsPerSecond", 1_000, MAX_CAPTURE_RATE), MAX_CAPTURE_RATE),
272
+ alertCooldownMs: readPositiveInteger(overload, "alertCooldownMs", 60_000, MAX_COOLDOWN_MS),
273
+ }),
274
+ }),
259
275
  privacy: Object.freeze({
260
276
  redactKeys: normalizeRedactKeys(privacy),
261
277
  maxDepth: readPositiveInteger(privacy, "maxDepth", 5, MAX_NORMALIZATION_DEPTH),
@@ -6,6 +6,8 @@ export function createDiagnosticsState() {
6
6
  filterFailures: 0,
7
7
  beforeSendFailures: 0,
8
8
  eventsSuppressed: 0,
9
+ eventsDroppedOverload: 0,
10
+ capturesAfterShutdown: 0,
9
11
  };
10
12
  }
11
13
  export function snapshotDiagnostics(state, values) {
@@ -16,6 +18,8 @@ export function snapshotDiagnostics(state, values) {
16
18
  filterFailures: state.filterFailures,
17
19
  beforeSendFailures: state.beforeSendFailures,
18
20
  eventsSuppressed: state.eventsSuppressed,
21
+ eventsDroppedOverload: state.eventsDroppedOverload,
22
+ capturesAfterShutdown: state.capturesAfterShutdown,
19
23
  ...values,
20
24
  });
21
25
  }
@@ -47,14 +47,9 @@ export function createGroupStore(options) {
47
47
  severity: group.severity,
48
48
  });
49
49
  const evictLeastRecent = () => {
50
- let candidate;
51
- for (const group of groups.values()) {
52
- if (candidate === undefined || group.lastSeenMs < candidate.lastSeenMs) {
53
- candidate = group;
54
- }
55
- }
56
- if (candidate !== undefined) {
57
- groups.delete(candidate.fingerprint);
50
+ const oldestFingerprint = groups.keys().next().value;
51
+ if (typeof oldestFingerprint === "string") {
52
+ groups.delete(oldestFingerprint);
58
53
  evicted += 1;
59
54
  }
60
55
  };
@@ -80,6 +75,10 @@ export function createGroupStore(options) {
80
75
  };
81
76
  groups.set(fingerprint, group);
82
77
  }
78
+ else {
79
+ groups.delete(fingerprint);
80
+ groups.set(fingerprint, group);
81
+ }
83
82
  group.lastSeenMs = timestamp;
84
83
  group.totalCount += 1;
85
84
  group.severity = severity;
@@ -4,6 +4,10 @@ export const MAX_PENDING_ALERTS = 10_000;
4
4
  export const MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1_000;
5
5
  export const MAX_ALERT_THRESHOLD = 1_000_000;
6
6
  export const MAX_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1_000;
7
+ export const MAX_CAPTURE_RATE = 1_000_000;
8
+ export const MAX_NOTIFIER_TIMEOUT_MS = 60_000;
9
+ export const MAX_CIRCUIT_BREAKER_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
10
+ export const MAX_CIRCUIT_BREAKER_FAILURES = 100;
7
11
  export const MAX_NORMALIZATION_DEPTH = 20;
8
12
  export const MAX_NORMALIZATION_KEYS = 10_000;
9
13
  export const MAX_NORMALIZATION_STRING_LENGTH = 32_768;
@@ -94,7 +94,12 @@ const normalizeValue = (value, depth, state, limits) => {
94
94
  break;
95
95
  }
96
96
  state.keysVisited += 1;
97
- result[truncate(key, limits.maxStringLength)] = normalizeValue(readProperty(value, key), depth + 1, state, limits);
97
+ const child = readProperty(value, key);
98
+ const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
99
+ result[truncate(key, limits.maxStringLength)] =
100
+ normalizedKey === "stack" && typeof child === "string"
101
+ ? truncate(child, limits.maxStackLength)
102
+ : normalizeValue(child, depth + 1, state, limits);
98
103
  }
99
104
  return result;
100
105
  };
@@ -1,5 +1,8 @@
1
- import { MAX_PENDING_ALERTS } from "./limits.js";
1
+ import { MAX_CIRCUIT_BREAKER_COOLDOWN_MS, MAX_CIRCUIT_BREAKER_FAILURES, MAX_NOTIFIER_TIMEOUT_MS, MAX_PENDING_ALERTS, } from "./limits.js";
2
2
  const DEFAULT_FLUSH_TIMEOUT_MS = 5_000;
3
+ const DEFAULT_NOTIFIER_TIMEOUT_MS = 5_000;
4
+ const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
5
+ const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
3
6
  export function createNotificationQueue(options) {
4
7
  if (!Number.isSafeInteger(options.maxPendingAlerts) ||
5
8
  options.maxPendingAlerts <= 0 ||
@@ -9,6 +12,23 @@ export function createNotificationQueue(options) {
9
12
  if (options.concurrency !== 1) {
10
13
  throw new RangeError("concurrency must be 1");
11
14
  }
15
+ if (options.notifierTimeoutMs !== undefined &&
16
+ (!Number.isSafeInteger(options.notifierTimeoutMs) ||
17
+ options.notifierTimeoutMs <= 0 ||
18
+ options.notifierTimeoutMs > MAX_NOTIFIER_TIMEOUT_MS)) {
19
+ throw new RangeError("notifierTimeoutMs must be a positive integer");
20
+ }
21
+ if (options.notifierCircuitBreaker !== undefined) {
22
+ const { failureThreshold, cooldownMs } = options.notifierCircuitBreaker;
23
+ if (!Number.isSafeInteger(failureThreshold) ||
24
+ failureThreshold <= 0 ||
25
+ failureThreshold > MAX_CIRCUIT_BREAKER_FAILURES ||
26
+ !Number.isSafeInteger(cooldownMs) ||
27
+ cooldownMs <= 0 ||
28
+ cooldownMs > MAX_CIRCUIT_BREAKER_COOLDOWN_MS) {
29
+ throw new RangeError("notifierCircuitBreaker limits are invalid");
30
+ }
31
+ }
12
32
  const pendingJobs = [];
13
33
  let drainPromise;
14
34
  let resolveDrain;
@@ -17,6 +37,15 @@ export function createNotificationQueue(options) {
17
37
  let dropped = 0;
18
38
  let sent = 0;
19
39
  let failures = 0;
40
+ let timeouts = 0;
41
+ let circuitOpenSkips = 0;
42
+ let closed = false;
43
+ const notifierStates = new Map();
44
+ const notifierTimeoutMs = options.notifierTimeoutMs ?? DEFAULT_NOTIFIER_TIMEOUT_MS;
45
+ const circuit = options.notifierCircuitBreaker ?? {
46
+ failureThreshold: DEFAULT_CIRCUIT_FAILURE_THRESHOLD,
47
+ cooldownMs: DEFAULT_CIRCUIT_COOLDOWN_MS,
48
+ };
20
49
  const notifyFailure = (error, notifier) => {
21
50
  failures += 1;
22
51
  try {
@@ -35,19 +64,51 @@ export function createNotificationQueue(options) {
35
64
  resolveDrain = undefined;
36
65
  };
37
66
  const processJob = async (job) => {
38
- let jobSent = 0;
39
- let jobFailures = 0;
40
- for (const notifier of job.notifiers) {
67
+ const results = await Promise.all(job.notifiers.map(async (notifier) => {
68
+ const state = notifierStates.get(notifier) ?? { failures: 0, openUntil: 0 };
69
+ const timestamp = Date.now();
70
+ if (state.openUntil > timestamp) {
71
+ circuitOpenSkips += 1;
72
+ notifyFailure(new Error(`Notifier circuit is open: ${notifier.name}`), notifier);
73
+ return { sent: 0, failed: 1 };
74
+ }
75
+ if (state.openUntil !== 0) {
76
+ state.openUntil = 0;
77
+ state.failures = 0;
78
+ }
79
+ let timer;
41
80
  try {
42
- await notifier.send(job.alert);
81
+ const timeout = new Promise((_, reject) => {
82
+ timer = setTimeout(() => {
83
+ timeouts += 1;
84
+ reject(new Error(`Notifier timed out after ${notifierTimeoutMs}ms: ${notifier.name}`));
85
+ }, notifierTimeoutMs);
86
+ timer.unref?.();
87
+ });
88
+ await Promise.race([notifier.send(job.alert), timeout]);
89
+ if (timer !== undefined) {
90
+ clearTimeout(timer);
91
+ }
92
+ state.failures = 0;
93
+ notifierStates.set(notifier, state);
43
94
  sent += 1;
44
- jobSent += 1;
95
+ return { sent: 1, failed: 0 };
45
96
  }
46
97
  catch (error) {
47
- jobFailures += 1;
98
+ if (timer !== undefined) {
99
+ clearTimeout(timer);
100
+ }
101
+ state.failures += 1;
102
+ if (state.failures >= circuit.failureThreshold) {
103
+ state.openUntil = Date.now() + circuit.cooldownMs;
104
+ }
105
+ notifierStates.set(notifier, state);
48
106
  notifyFailure(error, notifier);
107
+ return { sent: 0, failed: 1 };
49
108
  }
50
- }
109
+ }));
110
+ const jobSent = results.reduce((total, result) => total + result.sent, 0);
111
+ const jobFailures = results.reduce((total, result) => total + result.failed, 0);
51
112
  try {
52
113
  job.onComplete?.({ notifierFailures: jobFailures, sent: jobSent });
53
114
  }
@@ -70,7 +131,7 @@ export function createNotificationQueue(options) {
70
131
  }
71
132
  };
72
133
  const enqueue = (alert, notifiers, onComplete) => {
73
- if (pendingJobs.length >= options.maxPendingAlerts) {
134
+ if (closed || pendingJobs.length >= options.maxPendingAlerts) {
74
135
  dropped += 1;
75
136
  return false;
76
137
  }
@@ -83,6 +144,10 @@ export function createNotificationQueue(options) {
83
144
  pump();
84
145
  return true;
85
146
  };
147
+ const close = (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) => {
148
+ closed = true;
149
+ return flush(timeoutMs);
150
+ };
86
151
  const flush = (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) => {
87
152
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
88
153
  return Promise.reject(new RangeError("timeoutMs must be a positive integer"));
@@ -108,10 +173,14 @@ export function createNotificationQueue(options) {
108
173
  return {
109
174
  enqueue,
110
175
  flush,
176
+ close,
177
+ isClosed: () => closed,
111
178
  pending: () => pendingJobs.length,
112
179
  alertsQueued: () => queued,
113
180
  alertsDropped: () => dropped,
114
181
  alertsSent: () => sent,
115
182
  notifierFailures: () => failures,
183
+ notifierTimeouts: () => timeouts,
184
+ notifierCircuitOpenSkips: () => circuitOpenSkips,
116
185
  };
117
186
  }
@@ -0,0 +1,125 @@
1
+ export const PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
2
+ const METRICS = [
3
+ {
4
+ name: "captured_events_total",
5
+ help: "Total events accepted by Wotchi.",
6
+ type: "counter",
7
+ value: (diagnostics) => diagnostics.capturedEvents,
8
+ },
9
+ {
10
+ name: "capture_failures_total",
11
+ help: "Capture failures contained by Wotchi.",
12
+ type: "counter",
13
+ value: (diagnostics) => diagnostics.captureFailures,
14
+ },
15
+ {
16
+ name: "groups_evicted_total",
17
+ help: "Groups evicted from bounded memory.",
18
+ type: "counter",
19
+ value: (diagnostics) => diagnostics.groupsEvicted,
20
+ },
21
+ {
22
+ name: "alerts_queued_total",
23
+ help: "Alerts admitted to the notification queue.",
24
+ type: "counter",
25
+ value: (diagnostics) => diagnostics.alertsQueued,
26
+ },
27
+ {
28
+ name: "alerts_dropped_total",
29
+ help: "Alerts dropped because the notification queue was full.",
30
+ type: "counter",
31
+ value: (diagnostics) => diagnostics.alertsDropped,
32
+ },
33
+ {
34
+ name: "alerts_sent_total",
35
+ help: "Alerts delivered successfully to notifiers.",
36
+ type: "counter",
37
+ value: (diagnostics) => diagnostics.alertsSent,
38
+ },
39
+ {
40
+ name: "notifier_failures_total",
41
+ help: "Notifier delivery failures contained by Wotchi.",
42
+ type: "counter",
43
+ value: (diagnostics) => diagnostics.notifierFailures,
44
+ },
45
+ {
46
+ name: "fingerprint_callback_failures_total",
47
+ help: "Fingerprint callback failures contained by Wotchi.",
48
+ type: "counter",
49
+ value: (diagnostics) => diagnostics.fingerprintCallbackFailures,
50
+ },
51
+ {
52
+ name: "filter_failures_total",
53
+ help: "Event filter failures contained by Wotchi.",
54
+ type: "counter",
55
+ value: (diagnostics) => diagnostics.filterFailures,
56
+ },
57
+ {
58
+ name: "before_send_failures_total",
59
+ help: "Before-send callback failures contained by Wotchi.",
60
+ type: "counter",
61
+ value: (diagnostics) => diagnostics.beforeSendFailures,
62
+ },
63
+ {
64
+ name: "events_suppressed_total",
65
+ help: "Events suppressed by filtering, thresholds, or cooldowns.",
66
+ type: "counter",
67
+ value: (diagnostics) => diagnostics.eventsSuppressed,
68
+ },
69
+ {
70
+ name: "events_dropped_overload_total",
71
+ help: "Events dropped before normalization by the optional overload admission limit.",
72
+ type: "counter",
73
+ value: (diagnostics) => diagnostics.eventsDroppedOverload,
74
+ },
75
+ {
76
+ name: "captures_after_shutdown_total",
77
+ help: "Capture calls ignored after client shutdown.",
78
+ type: "counter",
79
+ value: (diagnostics) => diagnostics.capturesAfterShutdown,
80
+ },
81
+ {
82
+ name: "notifier_timeouts_total",
83
+ help: "Notifier deliveries that exceeded the configured timeout.",
84
+ type: "counter",
85
+ value: (diagnostics) => diagnostics.notifierTimeouts,
86
+ },
87
+ {
88
+ name: "notifier_circuit_open_skips_total",
89
+ help: "Notifier deliveries skipped while the notifier circuit was open.",
90
+ type: "counter",
91
+ value: (diagnostics) => diagnostics.notifierCircuitOpenSkips,
92
+ },
93
+ {
94
+ name: "active_groups",
95
+ help: "Current in-memory incident groups.",
96
+ type: "gauge",
97
+ value: (diagnostics) => diagnostics.activeGroups,
98
+ },
99
+ {
100
+ name: "pending_alerts",
101
+ help: "Current alerts waiting for notifier delivery.",
102
+ type: "gauge",
103
+ value: (diagnostics) => diagnostics.pendingAlerts,
104
+ },
105
+ ];
106
+ const safeMetricValue = (value) => {
107
+ if (!Number.isFinite(value) || value <= 0) {
108
+ return 0;
109
+ }
110
+ return Math.min(Math.floor(value), Number.MAX_SAFE_INTEGER);
111
+ };
112
+ const renderMetrics = (diagnostics) => {
113
+ const lines = [];
114
+ for (const metric of METRICS) {
115
+ lines.push(`# HELP wotchi_${metric.name} ${metric.help}`, `# TYPE wotchi_${metric.name} ${metric.type}`, `wotchi_${metric.name} ${safeMetricValue(metric.value(diagnostics))}`);
116
+ }
117
+ lines.push("");
118
+ return lines.join("\n");
119
+ };
120
+ export function createWotchiPrometheusExporter(client) {
121
+ return Object.freeze({
122
+ contentType: PROMETHEUS_CONTENT_TYPE,
123
+ render: () => renderMetrics(client.getDiagnostics()),
124
+ });
125
+ }
@@ -60,6 +60,7 @@ const redactCardLikeValues = (value) => value.replace(/\b\d[\d -]{11,25}\d\b/g,
60
60
  const CONNECTION_URL_PATTERN = /\b(?:postgres(?:ql)?|rediss?|mongodb(?:\+srv)?):\/\/[^\s"'<>]+/gi;
61
61
  const CONNECTION_QUERY_SECRET_PATTERN = /([?&](?:password|passwd|pass|secret|token|api[-_]?key|access[-_]?token|refresh[-_]?token|sslpassword)=)[^&#\s]*/gi;
62
62
  const URL_TRAILING_PUNCTUATION = /[),.;!?]+$/;
63
+ const AUTHORITY_USERINFO_PATTERN = /\b[^:\s/@]+:[^\s/@]+@(?=(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])(?::\d+)?(?:[/?#\s,;.)]|$))/gi;
63
64
  const redactConnectionUrl = (candidate) => {
64
65
  let core = candidate;
65
66
  let suffix = "";
@@ -83,6 +84,7 @@ const redactConnectionUrl = (candidate) => {
83
84
  return `${core.slice(0, authorityStart)}${redactedAuthority}${remainder}${suffix}`;
84
85
  };
85
86
  const redactConnectionUrls = (value) => value.replace(CONNECTION_URL_PATTERN, redactConnectionUrl);
87
+ const redactAuthorityUserinfo = (value) => value.replace(AUTHORITY_USERINFO_PATTERN, `${REDACTED}@`);
86
88
  const SENSITIVE_QUERY_KEYS = new Set([
87
89
  "authorization",
88
90
  "cookie",
@@ -114,6 +116,7 @@ const redactSensitiveQueryValues = (value) => value.replace(/([?&])([^=&#\s]+)=(
114
116
  const redactWebhookPathSecrets = (value) => value.replace(/(https?:\/\/[^/\s]+\/(?:services|hooks|webhooks?)\/)[^/?#\s]+/gi, (_match, prefix) => prefix + REDACTED);
115
117
  const redactString = (value, maxStringLength) => {
116
118
  let result = redactConnectionUrls(value);
119
+ result = redactAuthorityUserinfo(result);
117
120
  result = redactWebhookPathSecrets(result);
118
121
  result = redactSensitiveQueryValues(result);
119
122
  result = result.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, REDACTED);
@@ -124,7 +127,7 @@ const redactString = (value, maxStringLength) => {
124
127
  const separatorIndex = match.search(/[:=]/);
125
128
  return separatorIndex < 0 ? REDACTED : `${match.slice(0, separatorIndex + 1)}${REDACTED}`;
126
129
  });
127
- result = result.replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, REDACTED);
130
+ result = result.replace(/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, REDACTED);
128
131
  result = result.replace(/\b(?:github_pat|ghp|xox[baprs]-|sk|pk|AKIA)[A-Za-z0-9_-]{8,}\b/gi, REDACTED);
129
132
  result = redactCardLikeValues(result);
130
133
  return result.slice(0, maxStringLength);
@@ -0,0 +1,106 @@
1
+ const DEFAULT_INTERVAL_MS = 5_000;
2
+ const MIN_INTERVAL_MS = 100;
3
+ const MAX_INTERVAL_MS = 60_000;
4
+ const MAX_THRESHOLD = Number.MAX_SAFE_INTEGER;
5
+ const positiveNumber = (value, fallback, field) => {
6
+ if (value === undefined) {
7
+ return fallback;
8
+ }
9
+ if (!Number.isFinite(value) || value <= 0 || value > MAX_THRESHOLD) {
10
+ throw new RangeError(`${field} must be a positive finite number`);
11
+ }
12
+ return value;
13
+ };
14
+ export function registerWotchiRuntimeWatcher(client, options = {}) {
15
+ const intervalMs = positiveNumber(options.intervalMs, DEFAULT_INTERVAL_MS, "intervalMs");
16
+ if (!Number.isSafeInteger(intervalMs) ||
17
+ intervalMs < MIN_INTERVAL_MS ||
18
+ intervalMs > MAX_INTERVAL_MS) {
19
+ throw new RangeError(`intervalMs must be an integer from ${MIN_INTERVAL_MS} to ${MAX_INTERVAL_MS}`);
20
+ }
21
+ const thresholds = {
22
+ cpuPercent: options.cpuPercent,
23
+ rssBytes: options.rssBytes,
24
+ heapUsedBytes: options.heapUsedBytes,
25
+ eventLoopDelayMs: options.eventLoopDelayMs,
26
+ pendingAlerts: options.pendingAlerts,
27
+ notifierFailures: options.notifierFailures,
28
+ };
29
+ for (const [field, value] of Object.entries(thresholds)) {
30
+ if (value !== undefined) {
31
+ positiveNumber(value, 1, field);
32
+ }
33
+ }
34
+ const alertThreshold = options.alertThreshold ?? 3;
35
+ if (!Number.isSafeInteger(alertThreshold) || alertThreshold <= 0 || alertThreshold > 1_000_000) {
36
+ throw new RangeError("alertThreshold must be a positive integer no greater than 1000000");
37
+ }
38
+ let previousCpu = process.cpuUsage();
39
+ let previousTime = process.hrtime.bigint();
40
+ let previousNotifierFailures = client.getDiagnostics().notifierFailures;
41
+ let expectedAt = Date.now() + intervalMs;
42
+ let registered = true;
43
+ const sample = () => {
44
+ if (!registered) {
45
+ return;
46
+ }
47
+ const timestamp = Date.now();
48
+ const elapsedMicros = Math.max(1, Number(process.hrtime.bigint() - previousTime) / 1_000);
49
+ const cpu = process.cpuUsage(previousCpu);
50
+ previousCpu = process.cpuUsage();
51
+ previousTime = process.hrtime.bigint();
52
+ const memory = process.memoryUsage();
53
+ const eventLoopDelayMs = Math.max(0, timestamp - expectedAt);
54
+ expectedAt = timestamp + intervalMs;
55
+ const cpuPercent = ((cpu.user + cpu.system) / elapsedMicros) * 100;
56
+ const diagnostics = client.getDiagnostics();
57
+ const notifierFailures = Math.max(0, diagnostics.notifierFailures - previousNotifierFailures);
58
+ previousNotifierFailures = diagnostics.notifierFailures;
59
+ const readings = [
60
+ { metric: "cpu-percent", value: cpuPercent, threshold: thresholds.cpuPercent },
61
+ { metric: "rss-bytes", value: memory.rss, threshold: thresholds.rssBytes },
62
+ { metric: "heap-used-bytes", value: memory.heapUsed, threshold: thresholds.heapUsedBytes },
63
+ {
64
+ metric: "event-loop-delay-ms",
65
+ value: eventLoopDelayMs,
66
+ threshold: thresholds.eventLoopDelayMs,
67
+ },
68
+ { metric: "pending-alerts", value: 0, threshold: thresholds.pendingAlerts },
69
+ {
70
+ metric: "notifier-failures",
71
+ value: notifierFailures,
72
+ threshold: thresholds.notifierFailures,
73
+ },
74
+ ];
75
+ for (const reading of readings) {
76
+ const value = reading.metric === "pending-alerts" ? diagnostics.pendingAlerts : reading.value;
77
+ if (reading.threshold === undefined || value < reading.threshold) {
78
+ continue;
79
+ }
80
+ client.captureEvent({
81
+ level: "error",
82
+ kind: "runtime-monitor",
83
+ message: `Wotchi runtime pressure: ${reading.metric}`,
84
+ fingerprint: `wotchi.runtime.${reading.metric}`,
85
+ severity: "high",
86
+ alertThreshold,
87
+ context: {
88
+ metric: reading.metric,
89
+ value: Math.round(value * 100) / 100,
90
+ threshold: reading.threshold,
91
+ },
92
+ });
93
+ }
94
+ };
95
+ const timer = setInterval(sample, intervalMs);
96
+ timer.unref?.();
97
+ return {
98
+ unregister: () => {
99
+ if (!registered) {
100
+ return;
101
+ }
102
+ registered = false;
103
+ clearInterval(timer);
104
+ },
105
+ };
106
+ }
package/dist/esm/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createWotchi } from "./core/client.js";
2
2
  import { registerWotchiProcessMonitor } from "./core/process-monitor.js";
3
+ import { createWotchiPrometheusExporter, PROMETHEUS_CONTENT_TYPE } from "./core/prometheus.js";
4
+ import { registerWotchiRuntimeWatcher } from "./core/runtime-monitor.js";
3
5
  import { consoleNotifier } from "./notifiers/console.js";
4
6
  import { telegramNotifier } from "./notifiers/telegram.js";
5
7
  import { webhookNotifier } from "./notifiers/webhook.js";
6
8
  export { WotchiConfigurationError } from "./core/errors.js";
7
- export { consoleNotifier, createWotchi, registerWotchiProcessMonitor, telegramNotifier, webhookNotifier, };
9
+ export { consoleNotifier, createWotchi, createWotchiPrometheusExporter, PROMETHEUS_CONTENT_TYPE, registerWotchiProcessMonitor, registerWotchiRuntimeWatcher, telegramNotifier, webhookNotifier, };
@@ -3,12 +3,28 @@ import { markExpressErrorCaptured } from "./state.js";
3
3
  export function createExpressErrorHandler(client, options) {
4
4
  return (error, request, response, next) => {
5
5
  markExpressErrorCaptured(request);
6
- try {
7
- const requestContext = getExpressRequestContext(request, response, options);
8
- client.captureException(error, undefined, requestContext === undefined ? undefined : { request: requestContext });
6
+ let captured = false;
7
+ const captureAfterResponse = () => {
8
+ if (captured) {
9
+ return;
10
+ }
11
+ captured = true;
12
+ response.off("finish", captureAfterResponse);
13
+ response.off("close", captureAfterResponse);
14
+ try {
15
+ const requestContext = getExpressRequestContext(request, response, options);
16
+ client.captureException(error, undefined, requestContext === undefined ? undefined : { request: requestContext });
17
+ }
18
+ catch {
19
+ // A capture failure must never change Express error handling.
20
+ }
21
+ };
22
+ if (response.writableEnded || response.finished) {
23
+ captureAfterResponse();
9
24
  }
10
- catch {
11
- // A capture failure must never change Express error handling.
25
+ else {
26
+ response.once("finish", captureAfterResponse);
27
+ response.once("close", captureAfterResponse);
12
28
  }
13
29
  next(error);
14
30
  };