@futurewindai/wotchi 0.1.0-beta.2 → 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 (103) hide show
  1. package/README.md +178 -47
  2. package/SECURITY.md +2 -2
  3. package/dist/cjs/core/admission.js +22 -0
  4. package/dist/cjs/core/client.js +416 -21
  5. package/dist/cjs/core/config.js +193 -18
  6. package/dist/cjs/core/diagnostics.js +16 -1
  7. package/dist/cjs/core/group-store.js +22 -9
  8. package/dist/cjs/core/incident-builder.js +18 -0
  9. package/dist/cjs/core/incident-policy.js +2 -7
  10. package/dist/cjs/core/limits.js +17 -0
  11. package/dist/cjs/core/normalize.js +16 -6
  12. package/dist/cjs/core/notification-queue.js +92 -6
  13. package/dist/cjs/core/prometheus.js +129 -0
  14. package/dist/cjs/core/redact.js +57 -8
  15. package/dist/cjs/core/runtime-monitor.js +109 -0
  16. package/dist/cjs/index.js +8 -1
  17. package/dist/cjs/integrations/express/error-handler.js +21 -4
  18. package/dist/cjs/integrations/express/index.js +2 -1
  19. package/dist/cjs/integrations/express/status-observer.js +6 -0
  20. package/dist/cjs/integrations/nest/capture.js +21 -0
  21. package/dist/cjs/integrations/nest/exception-filter.js +64 -8
  22. package/dist/cjs/integrations/nest/filter-wrapper.js +17 -0
  23. package/dist/cjs/integrations/nest/index.js +7 -1
  24. package/dist/cjs/integrations/nest/module.js +44 -0
  25. package/dist/cjs/integrations/nest/register.js +15 -1
  26. package/dist/cjs/integrations/request-context.js +83 -1
  27. package/dist/cjs/notifiers/alert-payload.js +20 -0
  28. package/dist/cjs/notifiers/console.js +85 -15
  29. package/dist/cjs/notifiers/telegram-format.js +75 -5
  30. package/dist/cjs/notifiers/webhook-http.js +394 -0
  31. package/dist/cjs/notifiers/webhook.js +28 -0
  32. package/dist/esm/core/admission.js +19 -0
  33. package/dist/esm/core/client.js +416 -21
  34. package/dist/esm/core/config.js +193 -18
  35. package/dist/esm/core/diagnostics.js +16 -1
  36. package/dist/esm/core/group-store.js +22 -9
  37. package/dist/esm/core/incident-builder.js +18 -0
  38. package/dist/esm/core/incident-policy.js +2 -7
  39. package/dist/esm/core/limits.js +14 -0
  40. package/dist/esm/core/normalize.js +16 -6
  41. package/dist/esm/core/notification-queue.js +92 -6
  42. package/dist/esm/core/prometheus.js +125 -0
  43. package/dist/esm/core/redact.js +57 -8
  44. package/dist/esm/core/runtime-monitor.js +106 -0
  45. package/dist/esm/index.js +4 -1
  46. package/dist/esm/integrations/express/error-handler.js +21 -4
  47. package/dist/esm/integrations/express/index.js +1 -1
  48. package/dist/esm/integrations/express/status-observer.js +6 -0
  49. package/dist/esm/integrations/nest/capture.js +17 -0
  50. package/dist/esm/integrations/nest/exception-filter.js +65 -9
  51. package/dist/esm/integrations/nest/filter-wrapper.js +14 -0
  52. package/dist/esm/integrations/nest/index.js +3 -1
  53. package/dist/esm/integrations/nest/module.js +41 -0
  54. package/dist/esm/integrations/nest/register.js +15 -1
  55. package/dist/esm/integrations/request-context.js +83 -1
  56. package/dist/esm/notifiers/alert-payload.js +16 -0
  57. package/dist/esm/notifiers/console.js +85 -15
  58. package/dist/esm/notifiers/telegram-format.js +75 -5
  59. package/dist/esm/notifiers/webhook-http.js +387 -0
  60. package/dist/esm/notifiers/webhook.js +24 -0
  61. package/dist/types/core/admission.d.ts +10 -0
  62. package/dist/types/core/config.d.ts +17 -1
  63. package/dist/types/core/diagnostics.d.ts +7 -1
  64. package/dist/types/core/incident-policy.d.ts +1 -1
  65. package/dist/types/core/limits.d.ts +14 -0
  66. package/dist/types/core/notification-queue.d.ts +14 -1
  67. package/dist/types/core/prometheus.d.ts +7 -0
  68. package/dist/types/core/runtime-monitor.d.ts +15 -0
  69. package/dist/types/core/types.d.ts +111 -2
  70. package/dist/types/index.d.ts +8 -2
  71. package/dist/types/integrations/express/index.d.ts +2 -2
  72. package/dist/types/integrations/nest/capture.d.ts +4 -0
  73. package/dist/types/integrations/nest/exception-filter.d.ts +6 -2
  74. package/dist/types/integrations/nest/filter-wrapper.d.ts +7 -0
  75. package/dist/types/integrations/nest/index.d.ts +6 -2
  76. package/dist/types/integrations/nest/module.d.ts +9 -0
  77. package/dist/types/integrations/nest/register.d.ts +3 -0
  78. package/dist/types/integrations/request-context.d.ts +3 -0
  79. package/dist/types/notifiers/alert-payload.d.ts +5 -0
  80. package/dist/types/notifiers/webhook-http.d.ts +37 -0
  81. package/dist/types/notifiers/webhook.d.ts +4 -0
  82. package/dist/types-cjs/core/admission.d.cts +10 -0
  83. package/dist/types-cjs/core/config.d.cts +17 -1
  84. package/dist/types-cjs/core/diagnostics.d.cts +7 -1
  85. package/dist/types-cjs/core/incident-policy.d.cts +1 -1
  86. package/dist/types-cjs/core/limits.d.cts +14 -0
  87. package/dist/types-cjs/core/notification-queue.d.cts +14 -1
  88. package/dist/types-cjs/core/prometheus.d.cts +7 -0
  89. package/dist/types-cjs/core/runtime-monitor.d.cts +15 -0
  90. package/dist/types-cjs/core/types.d.cts +111 -2
  91. package/dist/types-cjs/index.d.cts +8 -2
  92. package/dist/types-cjs/integrations/express/index.d.cts +2 -2
  93. package/dist/types-cjs/integrations/nest/capture.d.cts +4 -0
  94. package/dist/types-cjs/integrations/nest/exception-filter.d.cts +6 -2
  95. package/dist/types-cjs/integrations/nest/filter-wrapper.d.cts +7 -0
  96. package/dist/types-cjs/integrations/nest/index.d.cts +6 -2
  97. package/dist/types-cjs/integrations/nest/module.d.cts +9 -0
  98. package/dist/types-cjs/integrations/nest/register.d.cts +3 -0
  99. package/dist/types-cjs/integrations/request-context.d.cts +3 -0
  100. package/dist/types-cjs/notifiers/alert-payload.d.cts +5 -0
  101. package/dist/types-cjs/notifiers/webhook-http.d.cts +37 -0
  102. package/dist/types-cjs/notifiers/webhook.d.cts +4 -0
  103. package/package.json +17 -4
@@ -1,16 +1,33 @@
1
1
  import { WotchiConfigurationError } from "./errors.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";
2
3
  export { WotchiConfigurationError };
3
4
  const MAX_REDACT_KEYS = 100;
5
+ const MAX_RULES = 50;
6
+ const MAX_RULE_STRING_LENGTH = 200;
7
+ const MAX_LINK_TEMPLATE_LENGTH = 1_000;
8
+ const ALLOWED_LINK_PLACEHOLDERS = new Set([
9
+ "service",
10
+ "environment",
11
+ "release",
12
+ "instance",
13
+ "fingerprint",
14
+ "requestId",
15
+ "correlationId",
16
+ "traceId",
17
+ "spanId",
18
+ "route",
19
+ "statusCode",
20
+ ]);
4
21
  const isRecord = (value) => typeof value === "object" && value !== null;
5
22
  const fail = (field) => {
6
23
  throw new WotchiConfigurationError(`${field} is invalid`);
7
24
  };
8
- const readPositiveInteger = (parent, field, defaultValue) => {
25
+ const readPositiveInteger = (parent, field, defaultValue, maximum) => {
9
26
  const value = parent?.[field];
10
27
  if (value === undefined) {
11
28
  return defaultValue;
12
29
  }
13
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
30
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > maximum) {
14
31
  return fail(field);
15
32
  }
16
33
  return value;
@@ -24,6 +41,120 @@ const readOptionalRecord = (value, field) => {
24
41
  }
25
42
  return value;
26
43
  };
44
+ const normalizeRuleString = (value, field) => {
45
+ if (value === undefined) {
46
+ return undefined;
47
+ }
48
+ if (typeof value !== "string" || value.trim().length === 0) {
49
+ return fail(field);
50
+ }
51
+ return value.trim().slice(0, MAX_RULE_STRING_LENGTH);
52
+ };
53
+ const normalizeSeverity = (value, field) => {
54
+ if (value === undefined) {
55
+ return undefined;
56
+ }
57
+ if (value !== "low" && value !== "medium" && value !== "high" && value !== "critical") {
58
+ return fail(field);
59
+ }
60
+ return value;
61
+ };
62
+ const freezeHook = (hook) => {
63
+ try {
64
+ return Object.freeze(hook);
65
+ }
66
+ catch {
67
+ return hook;
68
+ }
69
+ };
70
+ const normalizeLinks = (value) => {
71
+ if (value === undefined) {
72
+ return undefined;
73
+ }
74
+ if (!isRecord(value)) {
75
+ return fail("links");
76
+ }
77
+ const normalized = {};
78
+ for (const key of ["log", "trace"]) {
79
+ const raw = value[key];
80
+ if (raw === undefined) {
81
+ continue;
82
+ }
83
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_LINK_TEMPLATE_LENGTH) {
84
+ return fail(`links.${key}`);
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = new URL(raw.replace(/\{\{[^{}]+\}\}/g, "placeholder"));
89
+ }
90
+ catch {
91
+ return fail(`links.${key}`);
92
+ }
93
+ if (parsed.protocol !== "https:" ||
94
+ parsed.username !== "" ||
95
+ parsed.password !== "" ||
96
+ parsed.hash !== "") {
97
+ return fail(`links.${key}`);
98
+ }
99
+ const placeholders = raw.match(/\{\{([^{}]+)\}\}/g) ?? [];
100
+ if (/[{}]/.test(raw.replace(/\{\{[^{}]+\}\}/g, ""))) {
101
+ return fail(`links.${key}`);
102
+ }
103
+ for (const placeholder of placeholders) {
104
+ const name = placeholder.slice(2, -2);
105
+ if (!ALLOWED_LINK_PLACEHOLDERS.has(name)) {
106
+ return fail(`links.${key}`);
107
+ }
108
+ }
109
+ normalized[key] = raw;
110
+ }
111
+ return Object.freeze(normalized);
112
+ };
113
+ const normalizeRules = (value) => {
114
+ if (value === undefined) {
115
+ return Object.freeze([]);
116
+ }
117
+ if (!Array.isArray(value) || value.length > MAX_RULES) {
118
+ return fail("rules");
119
+ }
120
+ const rules = value.map((rawRule, index) => {
121
+ if (!isRecord(rawRule)) {
122
+ return fail(`rules[${index}]`);
123
+ }
124
+ const alertThreshold = rawRule.alertThreshold;
125
+ if (alertThreshold !== undefined &&
126
+ (typeof alertThreshold !== "number" ||
127
+ !Number.isSafeInteger(alertThreshold) ||
128
+ alertThreshold <= 0 ||
129
+ alertThreshold > 1_000_000)) {
130
+ return fail(`rules[${index}].alertThreshold`);
131
+ }
132
+ if (rawRule.ignore !== undefined && typeof rawRule.ignore !== "boolean") {
133
+ return fail(`rules[${index}].ignore`);
134
+ }
135
+ const environment = normalizeRuleString(rawRule.environment, `rules[${index}].environment`);
136
+ const route = normalizeRuleString(rawRule.route, `rules[${index}].route`);
137
+ const severity = normalizeSeverity(rawRule.severity, `rules[${index}].severity`);
138
+ const rule = {};
139
+ if (environment !== undefined) {
140
+ rule.environment = environment;
141
+ }
142
+ if (route !== undefined) {
143
+ rule.route = route;
144
+ }
145
+ if (rawRule.ignore !== undefined) {
146
+ rule.ignore = rawRule.ignore;
147
+ }
148
+ if (alertThreshold !== undefined) {
149
+ rule.alertThreshold = alertThreshold;
150
+ }
151
+ if (severity !== undefined) {
152
+ rule.severity = severity;
153
+ }
154
+ return Object.freeze(rule);
155
+ });
156
+ return Object.freeze(rules);
157
+ };
27
158
  const normalizeRedactKeys = (privacy) => {
28
159
  const value = privacy?.redactKeys;
29
160
  if (value === undefined) {
@@ -46,11 +177,19 @@ const normalizeNotifiers = (value) => {
46
177
  }
47
178
  for (let index = 0; index < value.length; index += 1) {
48
179
  const notifier = value[index];
49
- if (!isRecord(notifier) ||
50
- typeof notifier.name !== "string" ||
51
- notifier.name.trim().length === 0 ||
52
- typeof notifier.send !== "function") {
53
- return fail(`notifiers[${index}]`);
180
+ const valid = (() => {
181
+ try {
182
+ return (isRecord(notifier) &&
183
+ typeof notifier.name === "string" &&
184
+ notifier.name.trim().length > 0 &&
185
+ typeof notifier.send === "function");
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ })();
191
+ if (!valid) {
192
+ return fail("notifiers[" + index + "]");
54
193
  }
55
194
  }
56
195
  return Object.freeze([...value]);
@@ -71,12 +210,28 @@ export function validateConfig(config) {
71
210
  (typeof config.release !== "string" || config.release.trim().length === 0)) {
72
211
  return fail("release");
73
212
  }
213
+ if (config.instance !== undefined &&
214
+ (typeof config.instance !== "string" || config.instance.trim().length === 0)) {
215
+ return fail("instance");
216
+ }
74
217
  if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
75
218
  return fail("enabled");
76
219
  }
220
+ if (config.filter !== undefined && typeof config.filter !== "function") {
221
+ return fail("filter");
222
+ }
223
+ if (config.fingerprint !== undefined && typeof config.fingerprint !== "function") {
224
+ return fail("fingerprint");
225
+ }
226
+ if (config.beforeSend !== undefined && typeof config.beforeSend !== "function") {
227
+ return fail("beforeSend");
228
+ }
77
229
  const grouping = readOptionalRecord(config.grouping, "grouping");
78
230
  const queue = readOptionalRecord(config.queue, "queue");
231
+ const notifierCircuitBreaker = readOptionalRecord(queue?.notifierCircuitBreaker, "queue.notifierCircuitBreaker");
79
232
  const privacy = readOptionalRecord(config.privacy, "privacy");
233
+ const overload = readOptionalRecord(config.overload, "overload");
234
+ const links = normalizeLinks(config.links);
80
235
  const concurrency = queue?.concurrency;
81
236
  if (concurrency !== undefined && concurrency !== 1) {
82
237
  return fail("queue.concurrency");
@@ -84,25 +239,45 @@ export function validateConfig(config) {
84
239
  const normalized = {
85
240
  service: service.trim(),
86
241
  environment: environment.trim(),
87
- ...(config.release === undefined ? {} : { release: config.release.trim() }),
242
+ ...(config.instance === undefined ? {} : { instance: config.instance.trim().slice(0, 200) }),
243
+ ...(config.release === undefined ? {} : { release: config.release.trim().slice(0, 200) }),
88
244
  enabled: config.enabled ?? true,
245
+ ...(config.filter === undefined ? {} : { filter: freezeHook(config.filter) }),
246
+ ...(config.fingerprint === undefined ? {} : { fingerprint: freezeHook(config.fingerprint) }),
247
+ ...(config.beforeSend === undefined ? {} : { beforeSend: freezeHook(config.beforeSend) }),
248
+ ...(links === undefined ? {} : { links }),
249
+ rules: normalizeRules(config.rules),
89
250
  grouping: Object.freeze({
90
- windowMs: readPositiveInteger(grouping, "windowMs", 60_000),
91
- alertThreshold: readPositiveInteger(grouping, "alertThreshold", 3),
92
- cooldownMs: readPositiveInteger(grouping, "cooldownMs", 900_000),
93
- maxGroups: readPositiveInteger(grouping, "maxGroups", 200),
94
- maxEventsPerWindow: readPositiveInteger(grouping, "maxEventsPerWindow", 100),
251
+ windowMs: readPositiveInteger(grouping, "windowMs", 60_000, MAX_WINDOW_MS),
252
+ alertThreshold: readPositiveInteger(grouping, "alertThreshold", 3, MAX_ALERT_THRESHOLD),
253
+ cooldownMs: readPositiveInteger(grouping, "cooldownMs", 900_000, MAX_COOLDOWN_MS),
254
+ maxGroups: readPositiveInteger(grouping, "maxGroups", 200, MAX_GROUPS),
255
+ maxEventsPerWindow: readPositiveInteger(grouping, "maxEventsPerWindow", 100, MAX_EVENTS_PER_WINDOW),
95
256
  }),
96
257
  queue: Object.freeze({
97
- maxPendingAlerts: readPositiveInteger(queue, "maxPendingAlerts", 100),
258
+ maxPendingAlerts: readPositiveInteger(queue, "maxPendingAlerts", 100, MAX_PENDING_ALERTS),
98
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
+ }),
99
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
+ }),
100
275
  privacy: Object.freeze({
101
276
  redactKeys: normalizeRedactKeys(privacy),
102
- maxDepth: readPositiveInteger(privacy, "maxDepth", 5),
103
- maxKeys: readPositiveInteger(privacy, "maxKeys", 100),
104
- maxStringLength: readPositiveInteger(privacy, "maxStringLength", 500),
105
- maxStackLength: readPositiveInteger(privacy, "maxStackLength", 4_000),
277
+ maxDepth: readPositiveInteger(privacy, "maxDepth", 5, MAX_NORMALIZATION_DEPTH),
278
+ maxKeys: readPositiveInteger(privacy, "maxKeys", 100, MAX_NORMALIZATION_KEYS),
279
+ maxStringLength: readPositiveInteger(privacy, "maxStringLength", 500, MAX_NORMALIZATION_STRING_LENGTH),
280
+ maxStackLength: readPositiveInteger(privacy, "maxStackLength", 4_000, MAX_NORMALIZATION_STACK_LENGTH),
106
281
  }),
107
282
  notifiers: normalizeNotifiers(config.notifiers),
108
283
  };
@@ -1,10 +1,25 @@
1
1
  export function createDiagnosticsState() {
2
- return { capturedEvents: 0, captureFailures: 0 };
2
+ return {
3
+ capturedEvents: 0,
4
+ captureFailures: 0,
5
+ fingerprintCallbackFailures: 0,
6
+ filterFailures: 0,
7
+ beforeSendFailures: 0,
8
+ eventsSuppressed: 0,
9
+ eventsDroppedOverload: 0,
10
+ capturesAfterShutdown: 0,
11
+ };
3
12
  }
4
13
  export function snapshotDiagnostics(state, values) {
5
14
  return Object.freeze({
6
15
  capturedEvents: state.capturedEvents,
7
16
  captureFailures: state.captureFailures,
17
+ fingerprintCallbackFailures: state.fingerprintCallbackFailures,
18
+ filterFailures: state.filterFailures,
19
+ beforeSendFailures: state.beforeSendFailures,
20
+ eventsSuppressed: state.eventsSuppressed,
21
+ eventsDroppedOverload: state.eventsDroppedOverload,
22
+ capturesAfterShutdown: state.capturesAfterShutdown,
8
23
  ...values,
9
24
  });
10
25
  }
@@ -1,4 +1,5 @@
1
1
  import { normalizeUnknown } from "./normalize.js";
2
+ import { MAX_EVENTS_PER_WINDOW, MAX_GROUPS } from "./limits.js";
2
3
  import { createRollingWindow } from "./rolling-window.js";
3
4
  const cloneSafeEvent = (event) => {
4
5
  const context = event.context === undefined ? undefined : normalizeUnknown(event.context);
@@ -7,16 +8,29 @@ const cloneSafeEvent = (event) => {
7
8
  timestamp: event.timestamp,
8
9
  service: event.service,
9
10
  environment: event.environment,
11
+ ...(event.instance === undefined ? {} : { instance: event.instance }),
10
12
  ...(event.release === undefined ? {} : { release: event.release }),
13
+ ...(event.correlationId === undefined ? {} : { correlationId: event.correlationId }),
14
+ ...(event.operation === undefined ? {} : { operation: event.operation }),
15
+ ...(event.job === undefined ? {} : { job: event.job }),
16
+ ...(event.tags === undefined ? {} : { tags: { ...event.tags } }),
11
17
  error: { ...event.error },
12
18
  ...(event.request === undefined ? {} : { request: { ...event.request } }),
19
+ ...(event.trace === undefined ? {} : { trace: { ...event.trace } }),
13
20
  ...(context === undefined ? {} : { context: context }),
14
21
  };
15
22
  };
16
23
  export function createGroupStore(options) {
17
- if (!Number.isSafeInteger(options.maxGroups) || options.maxGroups <= 0) {
24
+ if (!Number.isSafeInteger(options.maxGroups) ||
25
+ options.maxGroups <= 0 ||
26
+ options.maxGroups > MAX_GROUPS) {
18
27
  throw new RangeError("maxGroups must be a positive integer");
19
28
  }
29
+ if (!Number.isSafeInteger(options.maxEventsPerWindow) ||
30
+ options.maxEventsPerWindow <= 0 ||
31
+ options.maxEventsPerWindow > MAX_EVENTS_PER_WINDOW) {
32
+ throw new RangeError("maxEventsPerWindow must be a positive integer");
33
+ }
20
34
  const now = options.now ?? Date.now;
21
35
  const groups = new Map();
22
36
  let evicted = 0;
@@ -33,14 +47,9 @@ export function createGroupStore(options) {
33
47
  severity: group.severity,
34
48
  });
35
49
  const evictLeastRecent = () => {
36
- let candidate;
37
- for (const group of groups.values()) {
38
- if (candidate === undefined || group.lastSeenMs < candidate.lastSeenMs) {
39
- candidate = group;
40
- }
41
- }
42
- if (candidate !== undefined) {
43
- groups.delete(candidate.fingerprint);
50
+ const oldestFingerprint = groups.keys().next().value;
51
+ if (typeof oldestFingerprint === "string") {
52
+ groups.delete(oldestFingerprint);
44
53
  evicted += 1;
45
54
  }
46
55
  };
@@ -66,6 +75,10 @@ export function createGroupStore(options) {
66
75
  };
67
76
  groups.set(fingerprint, group);
68
77
  }
78
+ else {
79
+ groups.delete(fingerprint);
80
+ groups.set(fingerprint, group);
81
+ }
69
82
  group.lastSeenMs = timestamp;
70
83
  group.totalCount += 1;
71
84
  group.severity = severity;
@@ -1,3 +1,4 @@
1
+ import { selectApplicationFrame } from "./stack-frame.js";
1
2
  const titleCase = (value) => `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
2
3
  const suggestedActions = (group) => {
3
4
  const text = `${group.sample.error.name} ${group.sample.error.message}`.toLowerCase();
@@ -27,6 +28,11 @@ const suggestedActions = (group) => {
27
28
  export function buildIncidentAlert(group, decision) {
28
29
  const message = group.sample.error.message;
29
30
  const errorName = group.sample.error.name;
31
+ const applicationFrame = selectApplicationFrame(group.sample.error.stack);
32
+ const error = {
33
+ ...group.sample.error,
34
+ ...(applicationFrame === undefined ? {} : { applicationFrame }),
35
+ };
30
36
  return {
31
37
  id: `wotchi-${group.fingerprint}-${group.lastSeenAt}`,
32
38
  fingerprint: group.fingerprint,
@@ -39,5 +45,17 @@ export function buildIncidentAlert(group, decision) {
39
45
  occurrences: group.windowCount,
40
46
  service: group.sample.service,
41
47
  environment: group.sample.environment,
48
+ ...(group.sample.instance === undefined ? {} : { instance: group.sample.instance }),
49
+ ...(group.sample.release === undefined ? {} : { release: group.sample.release }),
50
+ ...(group.sample.correlationId === undefined
51
+ ? {}
52
+ : { correlationId: group.sample.correlationId }),
53
+ ...(group.sample.operation === undefined ? {} : { operation: group.sample.operation }),
54
+ ...(group.sample.job === undefined ? {} : { job: group.sample.job }),
55
+ ...(group.sample.tags === undefined ? {} : { tags: group.sample.tags }),
56
+ error,
57
+ ...(group.sample.request === undefined ? {} : { request: group.sample.request }),
58
+ ...(group.sample.trace === undefined ? {} : { trace: group.sample.trace }),
59
+ ...(group.sample.context === undefined ? {} : { context: group.sample.context }),
42
60
  };
43
61
  }
@@ -10,13 +10,8 @@ export function evaluateIncidentPolicy(input) {
10
10
  const eventKind = input.eventKind ?? "error";
11
11
  const isCritical = eventKind === "process-monitor";
12
12
  const isHigh = input.group.windowCount >= 20 || hasUnavailableResponse(input.group);
13
- const severity = isCritical
14
- ? "critical"
15
- : isHigh
16
- ? "high"
17
- : eventKind === "manual"
18
- ? (input.manualSeverity ?? "low")
19
- : "medium";
13
+ const severity = input.manualSeverity ??
14
+ (isCritical ? "critical" : isHigh ? "high" : eventKind === "manual" ? "low" : "medium");
20
15
  const eligible = isCritical ||
21
16
  isHigh ||
22
17
  eventKind === "manual" ||
@@ -0,0 +1,14 @@
1
+ export const MAX_GROUPS = 10_000;
2
+ export const MAX_EVENTS_PER_WINDOW = 10_000;
3
+ export const MAX_PENDING_ALERTS = 10_000;
4
+ export const MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1_000;
5
+ export const MAX_ALERT_THRESHOLD = 1_000_000;
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;
11
+ export const MAX_NORMALIZATION_DEPTH = 20;
12
+ export const MAX_NORMALIZATION_KEYS = 10_000;
13
+ export const MAX_NORMALIZATION_STRING_LENGTH = 32_768;
14
+ export const MAX_NORMALIZATION_STACK_LENGTH = 32_768;
@@ -2,18 +2,19 @@ const UNREADABLE_VALUE = "[unreadable value]";
2
2
  const MAX_DEPTH_VALUE = "[MaxDepth]";
3
3
  const CIRCULAR_VALUE = "[Circular]";
4
4
  const MAX_KEYS_VALUE = "[MaxKeys]";
5
+ const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
5
6
  const defaultLimits = {
6
7
  maxDepth: 5,
7
8
  maxKeys: 100,
8
9
  maxStringLength: 500,
9
10
  maxStackLength: 4_000,
10
11
  };
11
- const boundedLimit = (value, fallback) => Number.isSafeInteger(value) && value > 0 ? value : fallback;
12
+ const boundedLimit = (value, fallback, maximum) => Number.isSafeInteger(value) && value > 0 && value <= maximum ? value : fallback;
12
13
  const normalizeLimits = (limits) => ({
13
- maxDepth: boundedLimit(limits?.maxDepth ?? defaultLimits.maxDepth, defaultLimits.maxDepth),
14
- maxKeys: boundedLimit(limits?.maxKeys ?? defaultLimits.maxKeys, defaultLimits.maxKeys),
15
- maxStringLength: boundedLimit(limits?.maxStringLength ?? defaultLimits.maxStringLength, defaultLimits.maxStringLength),
16
- maxStackLength: boundedLimit(limits?.maxStackLength ?? defaultLimits.maxStackLength, defaultLimits.maxStackLength),
14
+ maxDepth: boundedLimit(limits?.maxDepth ?? defaultLimits.maxDepth, defaultLimits.maxDepth, MAX_NORMALIZATION_DEPTH),
15
+ maxKeys: boundedLimit(limits?.maxKeys ?? defaultLimits.maxKeys, defaultLimits.maxKeys, MAX_NORMALIZATION_KEYS),
16
+ maxStringLength: boundedLimit(limits?.maxStringLength ?? defaultLimits.maxStringLength, defaultLimits.maxStringLength, MAX_NORMALIZATION_STRING_LENGTH),
17
+ maxStackLength: boundedLimit(limits?.maxStackLength ?? defaultLimits.maxStackLength, defaultLimits.maxStackLength, MAX_NORMALIZATION_STACK_LENGTH),
17
18
  });
18
19
  const truncate = (value, limit) => value.slice(0, limit);
19
20
  const readProperty = (value, key) => {
@@ -85,12 +86,20 @@ const normalizeValue = (value, depth, state, limits) => {
85
86
  }
86
87
  const result = {};
87
88
  for (const key of keys) {
89
+ if (UNSAFE_OBJECT_KEYS.has(key)) {
90
+ continue;
91
+ }
88
92
  if (state.keysVisited >= limits.maxKeys - 1) {
89
93
  result["[truncated]"] = MAX_KEYS_VALUE;
90
94
  break;
91
95
  }
92
96
  state.keysVisited += 1;
93
- 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);
94
103
  }
95
104
  return result;
96
105
  };
@@ -132,3 +141,4 @@ export function normalizeError(error, limits) {
132
141
  const message = typeof normalized === "string" ? normalized : UNREADABLE_VALUE;
133
142
  return { name: "UnknownError", message };
134
143
  }
144
+ import { MAX_NORMALIZATION_DEPTH, MAX_NORMALIZATION_KEYS, MAX_NORMALIZATION_STACK_LENGTH, MAX_NORMALIZATION_STRING_LENGTH, } from "./limits.js";
@@ -1,11 +1,34 @@
1
+ import { MAX_CIRCUIT_BREAKER_COOLDOWN_MS, MAX_CIRCUIT_BREAKER_FAILURES, MAX_NOTIFIER_TIMEOUT_MS, MAX_PENDING_ALERTS, } from "./limits.js";
1
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;
2
6
  export function createNotificationQueue(options) {
3
- if (!Number.isSafeInteger(options.maxPendingAlerts) || options.maxPendingAlerts <= 0) {
7
+ if (!Number.isSafeInteger(options.maxPendingAlerts) ||
8
+ options.maxPendingAlerts <= 0 ||
9
+ options.maxPendingAlerts > MAX_PENDING_ALERTS) {
4
10
  throw new RangeError("maxPendingAlerts must be a positive integer");
5
11
  }
6
12
  if (options.concurrency !== 1) {
7
13
  throw new RangeError("concurrency must be 1");
8
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
+ }
9
32
  const pendingJobs = [];
10
33
  let drainPromise;
11
34
  let resolveDrain;
@@ -14,6 +37,15 @@ export function createNotificationQueue(options) {
14
37
  let dropped = 0;
15
38
  let sent = 0;
16
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
+ };
17
49
  const notifyFailure = (error, notifier) => {
18
50
  failures += 1;
19
51
  try {
@@ -32,14 +64,56 @@ export function createNotificationQueue(options) {
32
64
  resolveDrain = undefined;
33
65
  };
34
66
  const processJob = async (job) => {
35
- 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;
36
80
  try {
37
- 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);
38
94
  sent += 1;
95
+ return { sent: 1, failed: 0 };
39
96
  }
40
97
  catch (error) {
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);
41
106
  notifyFailure(error, notifier);
107
+ return { sent: 0, failed: 1 };
42
108
  }
109
+ }));
110
+ const jobSent = results.reduce((total, result) => total + result.sent, 0);
111
+ const jobFailures = results.reduce((total, result) => total + result.failed, 0);
112
+ try {
113
+ job.onComplete?.({ notifierFailures: jobFailures, sent: jobSent });
114
+ }
115
+ catch {
116
+ // Job observers cannot be allowed to affect notification processing.
43
117
  }
44
118
  };
45
119
  const pump = () => {
@@ -56,16 +130,24 @@ export function createNotificationQueue(options) {
56
130
  });
57
131
  }
58
132
  };
59
- const enqueue = (alert, notifiers) => {
60
- if (pendingJobs.length >= options.maxPendingAlerts) {
133
+ const enqueue = (alert, notifiers, onComplete) => {
134
+ if (closed || pendingJobs.length >= options.maxPendingAlerts) {
61
135
  dropped += 1;
62
136
  return false;
63
137
  }
64
- pendingJobs.push({ alert, notifiers: [...notifiers] });
138
+ pendingJobs.push({
139
+ alert,
140
+ notifiers: [...notifiers],
141
+ ...(onComplete === undefined ? {} : { onComplete }),
142
+ });
65
143
  queued += 1;
66
144
  pump();
67
145
  return true;
68
146
  };
147
+ const close = (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) => {
148
+ closed = true;
149
+ return flush(timeoutMs);
150
+ };
69
151
  const flush = (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) => {
70
152
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
71
153
  return Promise.reject(new RangeError("timeoutMs must be a positive integer"));
@@ -91,10 +173,14 @@ export function createNotificationQueue(options) {
91
173
  return {
92
174
  enqueue,
93
175
  flush,
176
+ close,
177
+ isClosed: () => closed,
94
178
  pending: () => pendingJobs.length,
95
179
  alertsQueued: () => queued,
96
180
  alertsDropped: () => dropped,
97
181
  alertsSent: () => sent,
98
182
  notifierFailures: () => failures,
183
+ notifierTimeouts: () => timeouts,
184
+ notifierCircuitOpenSkips: () => circuitOpenSkips,
99
185
  };
100
186
  }