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

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