@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.
- package/README.md +78 -24
- package/SECURITY.md +2 -2
- package/dist/cjs/core/admission.js +22 -0
- package/dist/cjs/core/client.js +46 -0
- package/dist/cjs/core/config.js +16 -0
- package/dist/cjs/core/diagnostics.js +4 -0
- package/dist/cjs/core/group-store.js +7 -8
- package/dist/cjs/core/limits.js +5 -1
- package/dist/cjs/core/normalize.js +6 -1
- package/dist/cjs/core/notification-queue.js +77 -8
- package/dist/cjs/core/prometheus.js +129 -0
- package/dist/cjs/core/redact.js +4 -1
- package/dist/cjs/core/runtime-monitor.js +109 -0
- package/dist/cjs/index.js +6 -1
- package/dist/cjs/integrations/express/error-handler.js +21 -5
- package/dist/cjs/integrations/nest/capture.js +21 -0
- package/dist/cjs/integrations/nest/exception-filter.js +62 -14
- package/dist/cjs/integrations/nest/filter-wrapper.js +17 -0
- package/dist/cjs/integrations/nest/index.js +6 -1
- package/dist/cjs/integrations/nest/module.js +44 -0
- package/dist/cjs/integrations/nest/register.js +15 -1
- package/dist/esm/core/admission.js +19 -0
- package/dist/esm/core/client.js +46 -0
- package/dist/esm/core/config.js +17 -1
- package/dist/esm/core/diagnostics.js +4 -0
- package/dist/esm/core/group-store.js +7 -8
- package/dist/esm/core/limits.js +4 -0
- package/dist/esm/core/normalize.js +6 -1
- package/dist/esm/core/notification-queue.js +78 -9
- package/dist/esm/core/prometheus.js +125 -0
- package/dist/esm/core/redact.js +4 -1
- package/dist/esm/core/runtime-monitor.js +106 -0
- package/dist/esm/index.js +3 -1
- package/dist/esm/integrations/express/error-handler.js +21 -5
- package/dist/esm/integrations/nest/capture.js +17 -0
- package/dist/esm/integrations/nest/exception-filter.js +63 -15
- package/dist/esm/integrations/nest/filter-wrapper.js +14 -0
- package/dist/esm/integrations/nest/index.js +2 -0
- package/dist/esm/integrations/nest/module.js +41 -0
- package/dist/esm/integrations/nest/register.js +15 -1
- package/dist/types/core/admission.d.ts +10 -0
- package/dist/types/core/config.d.ts +10 -0
- package/dist/types/core/diagnostics.d.ts +3 -1
- package/dist/types/core/incident-policy.d.ts +1 -1
- package/dist/types/core/limits.d.ts +4 -0
- package/dist/types/core/notification-queue.d.ts +9 -0
- package/dist/types/core/prometheus.d.ts +7 -0
- package/dist/types/core/runtime-monitor.d.ts +15 -0
- package/dist/types/core/types.d.ts +16 -1
- package/dist/types/index.d.ts +5 -1
- package/dist/types/integrations/nest/capture.d.ts +4 -0
- package/dist/types/integrations/nest/exception-filter.d.ts +6 -2
- package/dist/types/integrations/nest/filter-wrapper.d.ts +7 -0
- package/dist/types/integrations/nest/index.d.ts +4 -0
- package/dist/types/integrations/nest/module.d.ts +9 -0
- package/dist/types/integrations/nest/register.d.ts +3 -0
- package/dist/types-cjs/core/admission.d.cts +10 -0
- package/dist/types-cjs/core/config.d.cts +10 -0
- package/dist/types-cjs/core/diagnostics.d.cts +3 -1
- package/dist/types-cjs/core/incident-policy.d.cts +1 -1
- package/dist/types-cjs/core/limits.d.cts +4 -0
- package/dist/types-cjs/core/notification-queue.d.cts +9 -0
- package/dist/types-cjs/core/prometheus.d.cts +7 -0
- package/dist/types-cjs/core/runtime-monitor.d.cts +15 -0
- package/dist/types-cjs/core/types.d.cts +16 -1
- package/dist/types-cjs/index.d.cts +5 -1
- package/dist/types-cjs/integrations/nest/capture.d.cts +4 -0
- package/dist/types-cjs/integrations/nest/exception-filter.d.cts +6 -2
- package/dist/types-cjs/integrations/nest/filter-wrapper.d.cts +7 -0
- package/dist/types-cjs/integrations/nest/index.d.cts +4 -0
- package/dist/types-cjs/integrations/nest/module.d.cts +9 -0
- package/dist/types-cjs/integrations/nest/register.d.cts +3 -0
- package/package.json +12 -1
package/dist/esm/core/client.js
CHANGED
|
@@ -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
|
}
|
package/dist/esm/core/config.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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;
|
package/dist/esm/core/limits.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
return { sent: 1, failed: 0 };
|
|
45
96
|
}
|
|
46
97
|
catch (error) {
|
|
47
|
-
|
|
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
|
+
}
|
package/dist/esm/core/redact.js
CHANGED
|
@@ -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_-]{
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
25
|
+
else {
|
|
26
|
+
response.once("finish", captureAfterResponse);
|
|
27
|
+
response.once("close", captureAfterResponse);
|
|
12
28
|
}
|
|
13
29
|
next(error);
|
|
14
30
|
};
|