@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,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";
@@ -10,6 +11,34 @@ import { redactError, redactValue } from "./redact.js";
10
11
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
12
  const asString = (value) => typeof value === "string" ? value : undefined;
12
13
  const asStatusCode = (value) => typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
14
+ const deepFreeze = (value) => {
15
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
16
+ return value;
17
+ }
18
+ Object.freeze(value);
19
+ for (const child of Object.values(value)) {
20
+ deepFreeze(child);
21
+ }
22
+ return value;
23
+ };
24
+ const safeTrace = (value, privacy) => {
25
+ if (value === undefined) {
26
+ return undefined;
27
+ }
28
+ const redacted = redactValue(value, privacy);
29
+ if (!isRecord(redacted)) {
30
+ return undefined;
31
+ }
32
+ const traceId = asString(redacted.traceId);
33
+ const spanId = asString(redacted.spanId);
34
+ if (traceId === undefined && spanId === undefined) {
35
+ return undefined;
36
+ }
37
+ return {
38
+ ...(traceId === undefined ? {} : { traceId: traceId.slice(0, 128) }),
39
+ ...(spanId === undefined ? {} : { spanId: spanId.slice(0, 128) }),
40
+ };
41
+ };
13
42
  const safeRecord = (value, privacy) => {
14
43
  if (value === undefined) {
15
44
  return undefined;
@@ -39,20 +68,48 @@ const safeRequest = (request, privacy) => {
39
68
  const method = asString(redacted.method);
40
69
  const route = asString(redacted.route);
41
70
  const requestId = asString(redacted.requestId);
71
+ const correlationId = asString(redacted.correlationId);
42
72
  const statusCode = asStatusCode(redacted.statusCode);
73
+ const trace = safeTrace(redacted.trace, privacy);
43
74
  if (method === undefined &&
44
75
  route === undefined &&
45
76
  requestId === undefined &&
46
- statusCode === undefined) {
77
+ correlationId === undefined &&
78
+ statusCode === undefined &&
79
+ trace === undefined) {
47
80
  return undefined;
48
81
  }
49
82
  return {
50
83
  ...(method === undefined ? {} : { method }),
51
84
  ...(route === undefined ? {} : { route }),
52
85
  ...(requestId === undefined ? {} : { requestId }),
86
+ ...(correlationId === undefined ? {} : { correlationId }),
53
87
  ...(statusCode === undefined ? {} : { statusCode }),
88
+ ...(trace === undefined ? {} : { trace }),
54
89
  };
55
90
  };
91
+ const safeTags = (value, privacy) => {
92
+ const redacted = redactValue(value, { ...privacy, maxKeys: Math.min(privacy.maxKeys, 50) });
93
+ if (!isRecord(redacted)) {
94
+ return undefined;
95
+ }
96
+ const tags = {};
97
+ for (const [key, tagValue] of Object.entries(redacted)) {
98
+ if (key.length > 64 || typeof tagValue !== "string") {
99
+ continue;
100
+ }
101
+ tags[key] = tagValue.slice(0, 200);
102
+ }
103
+ return Object.keys(tags).length === 0 ? undefined : tags;
104
+ };
105
+ const safeShortString = (value, privacy, maxLength) => {
106
+ if (value === undefined) {
107
+ return undefined;
108
+ }
109
+ const redacted = redactValue(value, { ...privacy, maxStringLength: maxLength });
110
+ const text = asString(redacted);
111
+ return text === undefined ? undefined : text.slice(0, maxLength);
112
+ };
56
113
  const safeMessage = (value, privacy) => {
57
114
  const normalized = normalizeUnknown(value, privacy);
58
115
  return typeof normalized === "string" ? normalized : "[unreadable value]";
@@ -66,6 +123,150 @@ const normalizeEventAlertThreshold = (value) => {
66
123
  }
67
124
  return value;
68
125
  };
126
+ const safeErrorFromValue = (value, privacy) => {
127
+ const redacted = redactValue(value, privacy);
128
+ if (!isRecord(redacted)) {
129
+ return undefined;
130
+ }
131
+ const name = asString(redacted.name);
132
+ const message = asString(redacted.message);
133
+ if (name === undefined || message === undefined) {
134
+ return undefined;
135
+ }
136
+ const stack = asString(redacted.stack);
137
+ const code = asString(redacted.code);
138
+ const applicationFrame = asString(redacted.applicationFrame);
139
+ return {
140
+ name,
141
+ message,
142
+ ...(stack === undefined ? {} : { stack }),
143
+ ...(code === undefined ? {} : { code }),
144
+ ...(applicationFrame === undefined
145
+ ? {}
146
+ : { applicationFrame: applicationFrame.slice(0, 1_000) }),
147
+ };
148
+ };
149
+ const safeAlertFromValue = (value, fallback, privacy) => {
150
+ const redacted = redactValue(value, privacy);
151
+ if (!isRecord(redacted)) {
152
+ return undefined;
153
+ }
154
+ const title = asString(redacted.title);
155
+ const fingerprint = asString(redacted.fingerprint);
156
+ const summary = asString(redacted.summary);
157
+ const severity = asString(redacted.severity);
158
+ const firstSeenAt = asString(redacted.firstSeenAt);
159
+ const lastSeenAt = asString(redacted.lastSeenAt);
160
+ const service = asString(redacted.service);
161
+ const environment = asString(redacted.environment);
162
+ const occurrences = asStatusCode(redacted.occurrences);
163
+ if (title === undefined ||
164
+ fingerprint === undefined ||
165
+ summary === undefined ||
166
+ firstSeenAt === undefined ||
167
+ lastSeenAt === undefined ||
168
+ service === undefined ||
169
+ environment === undefined ||
170
+ occurrences === undefined ||
171
+ (severity !== "low" && severity !== "medium" && severity !== "high" && severity !== "critical")) {
172
+ return undefined;
173
+ }
174
+ const rawActions = redacted.suggestedActions;
175
+ if (!Array.isArray(rawActions)) {
176
+ return undefined;
177
+ }
178
+ const suggestedActions = rawActions
179
+ .filter((action) => typeof action === "string")
180
+ .slice(0, 5)
181
+ .map((action) => action.slice(0, 600));
182
+ const error = safeErrorFromValue(redacted.error, privacy);
183
+ const request = safeRequest(redacted.request, privacy);
184
+ const trace = safeTrace(redacted.trace, privacy);
185
+ const context = safeRecord(redacted.context, privacy);
186
+ const tags = safeTags(redacted.tags, privacy);
187
+ const linksValue = safeRecord(redacted.links, privacy);
188
+ const logLink = linksValue === undefined ? undefined : asString(linksValue.log);
189
+ const traceLink = linksValue === undefined ? undefined : asString(linksValue.trace);
190
+ const links = linksValue
191
+ ? {
192
+ ...(logLink === undefined ? {} : { log: logLink.slice(0, 2_048) }),
193
+ ...(traceLink === undefined ? {} : { trace: traceLink.slice(0, 2_048) }),
194
+ }
195
+ : undefined;
196
+ const instance = asString(redacted.instance);
197
+ const release = asString(redacted.release);
198
+ const correlationId = asString(redacted.correlationId);
199
+ const operation = asString(redacted.operation);
200
+ const job = asString(redacted.job);
201
+ return deepFreeze({
202
+ id: asString(redacted.id) ?? fallback.id,
203
+ fingerprint: fingerprint.slice(0, 200),
204
+ title: title.slice(0, 300),
205
+ severity,
206
+ summary: summary.slice(0, 2_000),
207
+ suggestedActions,
208
+ firstSeenAt: firstSeenAt.slice(0, 100),
209
+ lastSeenAt: lastSeenAt.slice(0, 100),
210
+ occurrences: Math.max(0, Math.min(occurrences, Number.MAX_SAFE_INTEGER)),
211
+ service: service.slice(0, 300),
212
+ environment: environment.slice(0, 300),
213
+ ...(instance === undefined ? {} : { instance: instance.slice(0, 200) }),
214
+ ...(release === undefined ? {} : { release: release.slice(0, 200) }),
215
+ ...(correlationId === undefined ? {} : { correlationId: correlationId.slice(0, 200) }),
216
+ ...(operation === undefined ? {} : { operation: operation.slice(0, 200) }),
217
+ ...(job === undefined ? {} : { job: job.slice(0, 200) }),
218
+ ...(tags === undefined ? {} : { tags }),
219
+ ...(error === undefined ? {} : { error }),
220
+ ...(request === undefined ? {} : { request }),
221
+ ...(trace === undefined ? {} : { trace }),
222
+ ...(context === undefined ? {} : { context }),
223
+ ...(links === undefined || Object.keys(links).length === 0 ? {} : { links }),
224
+ });
225
+ };
226
+ const normalizeFingerprintOverride = (override, event) => {
227
+ if (override === undefined) {
228
+ return undefined;
229
+ }
230
+ const value = typeof override === "function" ? override(event) : override;
231
+ if (value === undefined) {
232
+ return undefined;
233
+ }
234
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > 200) {
235
+ throw new TypeError("fingerprint must be a non-empty string no longer than 200 characters");
236
+ }
237
+ return value.trim();
238
+ };
239
+ const renderLinks = (templates, event, fingerprint) => {
240
+ if (templates === undefined) {
241
+ return undefined;
242
+ }
243
+ const values = {
244
+ service: event.service,
245
+ environment: event.environment,
246
+ release: event.release ?? "",
247
+ instance: event.instance ?? "",
248
+ fingerprint,
249
+ requestId: event.request?.requestId ?? "",
250
+ correlationId: event.correlationId ?? event.request?.correlationId ?? "",
251
+ traceId: event.trace?.traceId ?? event.request?.trace?.traceId ?? "",
252
+ spanId: event.trace?.spanId ?? event.request?.trace?.spanId ?? "",
253
+ route: event.request?.route ?? "",
254
+ statusCode: event.request?.statusCode === undefined ? "" : String(event.request.statusCode),
255
+ };
256
+ const render = (template) => {
257
+ const expanded = template.replace(/\{\{([^{}]+)\}\}/g, (_match, key) => encodeURIComponent(values[key] ?? ""));
258
+ const redacted = redactValue(expanded, { maxStringLength: 2_048 });
259
+ return typeof redacted === "string" ? redacted.slice(0, 2_048) : "";
260
+ };
261
+ const result = {};
262
+ if (templates.log !== undefined) {
263
+ result.log = render(templates.log);
264
+ }
265
+ if (templates.trace !== undefined && (values.traceId !== "" || values.spanId !== "")) {
266
+ result.trace = render(templates.trace);
267
+ }
268
+ return Object.keys(result).length === 0 ? undefined : result;
269
+ };
69
270
  export function createWotchi(config) {
70
271
  const normalized = validateConfig(config);
71
272
  const now = Date.now;
@@ -79,48 +280,162 @@ export function createWotchi(config) {
79
280
  const queue = createNotificationQueue({
80
281
  maxPendingAlerts: normalized.queue.maxPendingAlerts,
81
282
  concurrency: normalized.queue.concurrency,
283
+ notifierTimeoutMs: normalized.queue.notifierTimeoutMs,
284
+ notifierCircuitBreaker: normalized.queue.notifierCircuitBreaker,
82
285
  });
286
+ const admission = normalized.overload === undefined
287
+ ? undefined
288
+ : createCaptureAdmission({
289
+ maxEventsPerSecond: normalized.overload.maxEventsPerSecond,
290
+ burst: normalized.overload.burst,
291
+ now,
292
+ });
83
293
  const privacy = normalized.privacy;
84
294
  let eventSequence = 0;
85
- const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold) => {
295
+ let overloadLastAlertAt = 0;
296
+ const matchingRule = (event) => normalized.rules.find((rule) => (rule.environment === undefined || rule.environment === event.environment) &&
297
+ (rule.route === undefined || rule.route === event.request?.route));
298
+ const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold, fingerprintOverride, severityOverride, trace, correlationId, operation, job, tags) => {
86
299
  const timestamp = now();
87
300
  const safeError = redactError(normalizeError(error, privacy), privacy);
88
301
  const requestContext = safeRequest(request, privacy);
89
302
  const eventContext = mergeContext(metadata, context, privacy);
303
+ const requestTrace = safeTrace(trace ?? requestContext?.trace, privacy);
304
+ const eventCorrelationId = safeShortString(correlationId ?? requestContext?.correlationId, privacy, 200);
305
+ const operationValue = safeShortString(operation ?? eventContext?.operation, privacy, 200);
306
+ const jobValue = safeShortString(job ?? eventContext?.job ?? eventContext?.queue, privacy, 200);
307
+ const eventTags = safeTags(tags, privacy);
90
308
  const safeEvent = {
91
309
  id: `event-${eventSequence + 1}`,
92
310
  timestamp: new Date(timestamp).toISOString(),
93
311
  service: normalized.service,
94
312
  environment: normalized.environment,
313
+ ...(normalized.instance === undefined ? {} : { instance: normalized.instance }),
95
314
  ...(normalized.release === undefined ? {} : { release: normalized.release }),
315
+ ...(eventCorrelationId === undefined ? {} : { correlationId: eventCorrelationId }),
316
+ ...(operationValue === undefined ? {} : { operation: operationValue.slice(0, 200) }),
317
+ ...(jobValue === undefined ? {} : { job: jobValue.slice(0, 200) }),
318
+ ...(eventTags === undefined ? {} : { tags: eventTags }),
96
319
  error: safeError,
97
320
  ...(requestContext === undefined ? {} : { request: requestContext }),
321
+ ...(requestTrace === undefined ? {} : { trace: requestTrace }),
98
322
  ...(eventContext === undefined ? {} : { context: eventContext }),
99
323
  };
100
324
  eventSequence += 1;
101
- const fingerprint = fingerprintSafeErrorEvent(safeEvent);
102
- const group = groupStore.record(fingerprint, safeEvent);
103
- const decision = evaluateIncidentPolicy({
325
+ const filteredEvent = deepFreeze(safeEvent);
326
+ if (normalized.filter !== undefined) {
327
+ try {
328
+ const accepted = normalized.filter(filteredEvent);
329
+ if (typeof accepted !== "boolean") {
330
+ diagnostics.filterFailures += 1;
331
+ diagnostics.eventsSuppressed += 1;
332
+ return;
333
+ }
334
+ if (!accepted) {
335
+ diagnostics.eventsSuppressed += 1;
336
+ return;
337
+ }
338
+ }
339
+ catch {
340
+ diagnostics.filterFailures += 1;
341
+ diagnostics.eventsSuppressed += 1;
342
+ return;
343
+ }
344
+ }
345
+ const rule = matchingRule(filteredEvent);
346
+ if (rule?.ignore === true) {
347
+ diagnostics.eventsSuppressed += 1;
348
+ return;
349
+ }
350
+ let fingerprint;
351
+ try {
352
+ fingerprint = normalizeFingerprintOverride(fingerprintOverride, filteredEvent);
353
+ }
354
+ catch {
355
+ diagnostics.fingerprintCallbackFailures += 1;
356
+ }
357
+ if (fingerprint === undefined && normalized.fingerprint !== undefined) {
358
+ try {
359
+ fingerprint = normalizeFingerprintOverride(normalized.fingerprint, filteredEvent);
360
+ }
361
+ catch {
362
+ diagnostics.fingerprintCallbackFailures += 1;
363
+ }
364
+ }
365
+ fingerprint ??= fingerprintSafeErrorEvent(filteredEvent);
366
+ const group = groupStore.record(fingerprint, filteredEvent, rule?.severity ?? severityOverride ?? "medium");
367
+ const policyInput = {
104
368
  group,
105
369
  now: timestamp,
106
- alertThreshold,
370
+ alertThreshold: rule?.alertThreshold ?? alertThreshold,
107
371
  cooldownMs: normalized.grouping.cooldownMs,
108
372
  eventKind,
109
- });
373
+ };
374
+ const manualSeverity = rule?.severity ?? severityOverride;
375
+ const decision = manualSeverity === undefined
376
+ ? evaluateIncidentPolicy(policyInput)
377
+ : evaluateIncidentPolicy({ ...policyInput, manualSeverity });
110
378
  if (decision.shouldAlert) {
111
- const alert = buildIncidentAlert(group, decision);
112
- if (queue.enqueue(alert, normalized.notifiers)) {
379
+ const builtAlert = buildIncidentAlert(group, decision);
380
+ const links = renderLinks(normalized.links, filteredEvent, fingerprint);
381
+ const alert = deepFreeze({
382
+ ...builtAlert,
383
+ ...(links === undefined ? {} : { links }),
384
+ });
385
+ let finalAlert = alert;
386
+ if (normalized.beforeSend !== undefined) {
387
+ let transformed;
388
+ try {
389
+ transformed = normalized.beforeSend(alert);
390
+ }
391
+ catch {
392
+ diagnostics.beforeSendFailures += 1;
393
+ diagnostics.eventsSuppressed += 1;
394
+ return;
395
+ }
396
+ if (transformed === null) {
397
+ diagnostics.eventsSuppressed += 1;
398
+ return;
399
+ }
400
+ if (transformed !== undefined) {
401
+ const sanitized = safeAlertFromValue(transformed, alert, privacy);
402
+ if (sanitized === undefined) {
403
+ diagnostics.beforeSendFailures += 1;
404
+ diagnostics.eventsSuppressed += 1;
405
+ return;
406
+ }
407
+ finalAlert = sanitized;
408
+ }
409
+ }
410
+ if (queue.enqueue(finalAlert, normalized.notifiers)) {
113
411
  groupStore.markAlerted(fingerprint, timestamp);
114
412
  }
115
413
  }
116
414
  diagnostics.capturedEvents += 1;
117
415
  };
118
- const captureException = (error, context) => {
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
+ };
424
+ const captureException = (error, context, options) => {
119
425
  if (!normalized.enabled) {
120
426
  return;
121
427
  }
428
+ if (queue.isClosed()) {
429
+ diagnostics.capturesAfterShutdown += 1;
430
+ return;
431
+ }
122
432
  try {
123
- captureSafeEvent(error, undefined, context, undefined);
433
+ if (admission !== undefined && !admission.tryAcquire()) {
434
+ diagnostics.eventsDroppedOverload += 1;
435
+ emitOverloadSignal(now());
436
+ return;
437
+ }
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);
124
439
  }
125
440
  catch {
126
441
  diagnostics.captureFailures += 1;
@@ -130,6 +445,10 @@ export function createWotchi(config) {
130
445
  if (!normalized.enabled) {
131
446
  return;
132
447
  }
448
+ if (queue.isClosed()) {
449
+ diagnostics.capturesAfterShutdown += 1;
450
+ return;
451
+ }
133
452
  try {
134
453
  if (event === null || typeof event !== "object" || event.level !== "error") {
135
454
  throw new TypeError("event.level must be error");
@@ -138,24 +457,100 @@ export function createWotchi(config) {
138
457
  throw new TypeError("event.message must be a string");
139
458
  }
140
459
  const alertThreshold = normalizeEventAlertThreshold(event.alertThreshold);
141
- captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error", alertThreshold ?? normalized.grouping.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
+ }
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);
142
469
  }
143
470
  catch {
144
471
  diagnostics.captureFailures += 1;
145
472
  }
146
473
  };
474
+ const readDiagnostics = () => snapshotDiagnostics(diagnostics, {
475
+ groupsEvicted: groupStore.groupsEvicted(),
476
+ alertsQueued: queue.alertsQueued(),
477
+ alertsDropped: queue.alertsDropped(),
478
+ alertsSent: queue.alertsSent(),
479
+ notifierFailures: queue.notifierFailures(),
480
+ notifierTimeouts: queue.notifierTimeouts(),
481
+ notifierCircuitOpenSkips: queue.notifierCircuitOpenSkips(),
482
+ activeGroups: groupStore.size(),
483
+ pendingAlerts: queue.pending(),
484
+ });
485
+ const testAlert = async () => {
486
+ const timestamp = new Date(now()).toISOString();
487
+ const alert = {
488
+ id: `wotchi-test-${timestamp}`,
489
+ fingerprint: "wotchi-test-alert",
490
+ title: "Wotchi — Test alert",
491
+ severity: "low",
492
+ summary: "This is a notifier configuration test alert.",
493
+ suggestedActions: ["Confirm this destination received the test alert."],
494
+ firstSeenAt: timestamp,
495
+ lastSeenAt: timestamp,
496
+ occurrences: 1,
497
+ service: normalized.service,
498
+ environment: normalized.environment,
499
+ ...(normalized.instance === undefined ? {} : { instance: normalized.instance }),
500
+ ...(normalized.release === undefined ? {} : { release: normalized.release }),
501
+ };
502
+ let jobResult;
503
+ if (!queue.enqueue(alert, normalized.notifiers, (result) => {
504
+ jobResult = result;
505
+ })) {
506
+ return {
507
+ status: "queue-full",
508
+ configurationAccepted: true,
509
+ queued: false,
510
+ flushed: false,
511
+ delivered: false,
512
+ notifierFailures: 0,
513
+ diagnostics: readDiagnostics(),
514
+ error: "Notification queue is full; test alert was not queued.",
515
+ };
516
+ }
517
+ try {
518
+ await queue.flush();
519
+ }
520
+ catch {
521
+ return {
522
+ status: "timeout",
523
+ configurationAccepted: true,
524
+ queued: true,
525
+ flushed: false,
526
+ delivered: false,
527
+ notifierFailures: jobResult?.notifierFailures ?? 0,
528
+ diagnostics: readDiagnostics(),
529
+ error: "Notification queue flush timed out.",
530
+ };
531
+ }
532
+ const notifierFailures = jobResult?.notifierFailures ?? 0;
533
+ const status = notifierFailures > 0 ? "notifier-failed" : "sent";
534
+ return {
535
+ status,
536
+ configurationAccepted: true,
537
+ queued: true,
538
+ flushed: true,
539
+ delivered: status === "sent",
540
+ notifierFailures,
541
+ diagnostics: readDiagnostics(),
542
+ ...(status === "notifier-failed" ? { error: "One or more notifiers failed." } : {}),
543
+ };
544
+ };
545
+ const shutdown = async (timeoutMs) => {
546
+ await queue.close(timeoutMs);
547
+ };
147
548
  return {
148
549
  captureException,
149
550
  captureEvent,
551
+ testAlert,
150
552
  flush: (timeoutMs) => queue.flush(timeoutMs),
151
- getDiagnostics: () => snapshotDiagnostics(diagnostics, {
152
- groupsEvicted: groupStore.groupsEvicted(),
153
- alertsQueued: queue.alertsQueued(),
154
- alertsDropped: queue.alertsDropped(),
155
- alertsSent: queue.alertsSent(),
156
- notifierFailures: queue.notifierFailures(),
157
- activeGroups: groupStore.size(),
158
- pendingAlerts: queue.pending(),
159
- }),
553
+ shutdown,
554
+ getDiagnostics: readDiagnostics,
160
555
  };
161
556
  }