@futurewindai/wotchi 0.1.0-beta.2 → 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.
- package/README.md +102 -25
- package/dist/cjs/core/client.js +370 -21
- package/dist/cjs/core/config.js +177 -18
- package/dist/cjs/core/diagnostics.js +12 -1
- package/dist/cjs/core/group-store.js +15 -1
- package/dist/cjs/core/incident-builder.js +18 -0
- package/dist/cjs/core/incident-policy.js +2 -7
- package/dist/cjs/core/limits.js +13 -0
- package/dist/cjs/core/normalize.js +10 -5
- package/dist/cjs/core/notification-queue.js +20 -3
- package/dist/cjs/core/redact.js +53 -7
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/integrations/express/error-handler.js +2 -1
- package/dist/cjs/integrations/express/index.js +2 -1
- package/dist/cjs/integrations/express/status-observer.js +6 -0
- package/dist/cjs/integrations/nest/exception-filter.js +9 -1
- package/dist/cjs/integrations/nest/index.js +2 -1
- package/dist/cjs/integrations/request-context.js +83 -1
- package/dist/cjs/notifiers/alert-payload.js +20 -0
- package/dist/cjs/notifiers/console.js +85 -15
- package/dist/cjs/notifiers/telegram-format.js +75 -5
- package/dist/cjs/notifiers/webhook-http.js +394 -0
- package/dist/cjs/notifiers/webhook.js +28 -0
- package/dist/esm/core/client.js +370 -21
- package/dist/esm/core/config.js +177 -18
- package/dist/esm/core/diagnostics.js +12 -1
- package/dist/esm/core/group-store.js +15 -1
- package/dist/esm/core/incident-builder.js +18 -0
- package/dist/esm/core/incident-policy.js +2 -7
- package/dist/esm/core/limits.js +10 -0
- package/dist/esm/core/normalize.js +10 -5
- package/dist/esm/core/notification-queue.js +20 -3
- package/dist/esm/core/redact.js +53 -7
- package/dist/esm/index.js +2 -1
- package/dist/esm/integrations/express/error-handler.js +2 -1
- package/dist/esm/integrations/express/index.js +1 -1
- package/dist/esm/integrations/express/status-observer.js +6 -0
- package/dist/esm/integrations/nest/exception-filter.js +9 -1
- package/dist/esm/integrations/nest/index.js +1 -1
- package/dist/esm/integrations/request-context.js +83 -1
- package/dist/esm/notifiers/alert-payload.js +16 -0
- package/dist/esm/notifiers/console.js +85 -15
- package/dist/esm/notifiers/telegram-format.js +75 -5
- package/dist/esm/notifiers/webhook-http.js +387 -0
- package/dist/esm/notifiers/webhook.js +24 -0
- package/dist/types/core/config.d.ts +7 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/limits.d.ts +10 -0
- package/dist/types/core/notification-queue.d.ts +5 -1
- package/dist/types/core/types.d.ts +95 -1
- package/dist/types/index.d.ts +4 -2
- package/dist/types/integrations/express/index.d.ts +2 -2
- package/dist/types/integrations/nest/index.d.ts +2 -2
- package/dist/types/integrations/request-context.d.ts +3 -0
- package/dist/types/notifiers/alert-payload.d.ts +5 -0
- package/dist/types/notifiers/webhook-http.d.ts +37 -0
- package/dist/types/notifiers/webhook.d.ts +4 -0
- package/dist/types-cjs/core/config.d.cts +7 -1
- package/dist/types-cjs/core/diagnostics.d.cts +5 -1
- package/dist/types-cjs/core/limits.d.cts +10 -0
- package/dist/types-cjs/core/notification-queue.d.cts +5 -1
- package/dist/types-cjs/core/types.d.cts +95 -1
- package/dist/types-cjs/index.d.cts +4 -2
- package/dist/types-cjs/integrations/express/index.d.cts +2 -2
- package/dist/types-cjs/integrations/nest/index.d.cts +2 -2
- package/dist/types-cjs/integrations/request-context.d.cts +3 -0
- package/dist/types-cjs/notifiers/alert-payload.d.cts +5 -0
- package/dist/types-cjs/notifiers/webhook-http.d.cts +37 -0
- package/dist/types-cjs/notifiers/webhook.d.cts +4 -0
- package/package.json +6 -4
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import dns from "node:dns";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { toBoundedAlertPayload, toBoundedPayload } from "./alert-payload.js";
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 3_000;
|
|
7
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
8
|
+
const MAX_RESPONSE_BYTES = 8_192;
|
|
9
|
+
const MAX_PAYLOAD_BYTES = 32_768;
|
|
10
|
+
const MAX_URL_LENGTH = 2_048;
|
|
11
|
+
const MAX_HEADER_COUNT = 20;
|
|
12
|
+
const MAX_HEADER_VALUE_LENGTH = 2_000;
|
|
13
|
+
const wait = (delayMs) => new Promise((resolve) => {
|
|
14
|
+
setTimeout(resolve, delayMs);
|
|
15
|
+
});
|
|
16
|
+
const normalizeHost = (value) => value
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/^\[|\]$/g, "")
|
|
19
|
+
.replace(/\.$/g, "");
|
|
20
|
+
const parseIPv4 = (value) => {
|
|
21
|
+
const parts = value.split(".");
|
|
22
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const numbers = parts.map(Number);
|
|
26
|
+
return numbers.every((part) => part >= 0 && part <= 255) ? numbers : undefined;
|
|
27
|
+
};
|
|
28
|
+
const isPrivateIPv4 = (value) => {
|
|
29
|
+
const parts = parseIPv4(value);
|
|
30
|
+
if (parts === undefined) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const first = parts[0] ?? -1;
|
|
34
|
+
const second = parts[1] ?? -1;
|
|
35
|
+
return (first === 0 ||
|
|
36
|
+
first === 10 ||
|
|
37
|
+
first === 127 ||
|
|
38
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
39
|
+
(first === 169 && second === 254) ||
|
|
40
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
41
|
+
(first === 192 && second === 168) ||
|
|
42
|
+
(first === 198 && (second === 18 || second === 19)));
|
|
43
|
+
};
|
|
44
|
+
const parseIPv6 = (value) => {
|
|
45
|
+
const host = normalizeHost(value);
|
|
46
|
+
const halves = host.split("::");
|
|
47
|
+
if (halves.length > 2) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const parseHalf = (half) => {
|
|
51
|
+
if (half === "") {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const groups = half.split(":");
|
|
55
|
+
const values = [];
|
|
56
|
+
for (const group of groups) {
|
|
57
|
+
if (group.includes(".")) {
|
|
58
|
+
const ipv4 = parseIPv4(group);
|
|
59
|
+
if (ipv4 === undefined) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
values.push((ipv4[0] ?? 0) * 256 + (ipv4[1] ?? 0));
|
|
63
|
+
values.push((ipv4[2] ?? 0) * 256 + (ipv4[3] ?? 0));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
values.push(Number.parseInt(group, 16));
|
|
70
|
+
}
|
|
71
|
+
return values;
|
|
72
|
+
};
|
|
73
|
+
const left = parseHalf(halves[0] ?? "");
|
|
74
|
+
const right = parseHalf(halves[1] ?? "");
|
|
75
|
+
if (left === undefined || right === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
|
|
79
|
+
if (missing < 1 || left.length + right.length + missing !== 8) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const groups = [...left, ...Array.from({ length: missing }, () => 0), ...right];
|
|
83
|
+
return groups.flatMap((group) => [(group >> 8) & 0xff, group & 0xff]);
|
|
84
|
+
};
|
|
85
|
+
const isPrivateIPv6 = (value) => {
|
|
86
|
+
const bytes = parseIPv6(value);
|
|
87
|
+
if (bytes === undefined) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const allZero = bytes.every((byte) => byte === 0);
|
|
91
|
+
const loopback = allZero || (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1);
|
|
92
|
+
const first = bytes[0] ?? 0;
|
|
93
|
+
const second = bytes[1] ?? 0;
|
|
94
|
+
if (loopback ||
|
|
95
|
+
first === 0xfc ||
|
|
96
|
+
first === 0xfd ||
|
|
97
|
+
(first === 0xfe && (second & 0xc0) === 0x80)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
const mapped = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
|
|
101
|
+
const compatible = bytes.slice(0, 12).every((byte) => byte === 0);
|
|
102
|
+
if (!mapped && !compatible) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return isPrivateIPv4(bytes.slice(12).join("."));
|
|
106
|
+
};
|
|
107
|
+
const isPrivateDestination = (value) => {
|
|
108
|
+
const host = normalizeHost(value);
|
|
109
|
+
if (host === "localhost" ||
|
|
110
|
+
host.endsWith(".localhost") ||
|
|
111
|
+
host.endsWith(".local") ||
|
|
112
|
+
host === "metadata.google.internal" ||
|
|
113
|
+
host.endsWith(".internal")) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const family = net.isIP(host);
|
|
117
|
+
return family === 4 ? isPrivateIPv4(host) : family === 6 && isPrivateIPv6(host);
|
|
118
|
+
};
|
|
119
|
+
const privateDestinationError = () => new TypeError("webhook private or internal destinations require allowPrivateDestinations");
|
|
120
|
+
const normalizeUrl = (value, allowHttpLoopback = false, allowPrivateDestinations = false) => {
|
|
121
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > MAX_URL_LENGTH) {
|
|
122
|
+
throw new TypeError("webhook url is invalid");
|
|
123
|
+
}
|
|
124
|
+
let url;
|
|
125
|
+
try {
|
|
126
|
+
url = new URL(value);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new TypeError("webhook url is invalid");
|
|
130
|
+
}
|
|
131
|
+
if (url.username !== "" || url.password !== "" || url.hash !== "") {
|
|
132
|
+
throw new TypeError("webhook url must not contain credentials or a fragment");
|
|
133
|
+
}
|
|
134
|
+
if (url.protocol === "https:" &&
|
|
135
|
+
!allowPrivateDestinations &&
|
|
136
|
+
isPrivateDestination(url.hostname)) {
|
|
137
|
+
throw privateDestinationError();
|
|
138
|
+
}
|
|
139
|
+
if (url.protocol === "https:") {
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
const loopback = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
143
|
+
if (url.protocol !== "http:" || !allowHttpLoopback || !loopback.has(url.hostname)) {
|
|
144
|
+
throw new TypeError("webhook url must be HTTPS or explicitly enabled loopback HTTP");
|
|
145
|
+
}
|
|
146
|
+
return url;
|
|
147
|
+
};
|
|
148
|
+
const normalizeHeaders = (headers) => {
|
|
149
|
+
if (headers === undefined) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const entries = Object.entries(headers);
|
|
153
|
+
if (entries.length > MAX_HEADER_COUNT) {
|
|
154
|
+
throw new TypeError("webhook headers exceed the maximum count");
|
|
155
|
+
}
|
|
156
|
+
const normalized = {};
|
|
157
|
+
for (const [name, value] of entries) {
|
|
158
|
+
if (!/^[A-Za-z0-9-]+$/.test(name) || name.length > 100) {
|
|
159
|
+
throw new TypeError("webhook header name is invalid");
|
|
160
|
+
}
|
|
161
|
+
if (typeof value !== "string" ||
|
|
162
|
+
value.length > MAX_HEADER_VALUE_LENGTH ||
|
|
163
|
+
/[\r\n]/.test(value)) {
|
|
164
|
+
throw new TypeError("webhook header value is invalid");
|
|
165
|
+
}
|
|
166
|
+
const normalizedName = name.toLowerCase();
|
|
167
|
+
if (normalizedName === "content-length" || normalizedName === "host") {
|
|
168
|
+
throw new TypeError(`webhook header ${normalizedName} is reserved`);
|
|
169
|
+
}
|
|
170
|
+
normalized[normalizedName] = value;
|
|
171
|
+
}
|
|
172
|
+
return normalized;
|
|
173
|
+
};
|
|
174
|
+
export function normalizeWebhookOptions(options) {
|
|
175
|
+
if (options.allowHttpLoopback !== undefined && typeof options.allowHttpLoopback !== "boolean") {
|
|
176
|
+
throw new TypeError("webhook allowHttpLoopback is invalid");
|
|
177
|
+
}
|
|
178
|
+
const allowHttpLoopback = options.allowHttpLoopback === true;
|
|
179
|
+
const allowPrivateDestinations = options.allowPrivateDestinations === true;
|
|
180
|
+
const url = normalizeUrl(options.url, allowHttpLoopback, allowPrivateDestinations);
|
|
181
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
182
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) {
|
|
183
|
+
throw new TypeError("webhook timeoutMs is invalid");
|
|
184
|
+
}
|
|
185
|
+
const maxRetries = options.maxRetries ?? 1;
|
|
186
|
+
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0 || maxRetries > 1) {
|
|
187
|
+
throw new TypeError("webhook maxRetries must be 0 or 1");
|
|
188
|
+
}
|
|
189
|
+
if (options.payloadBuilder !== undefined && typeof options.payloadBuilder !== "function") {
|
|
190
|
+
throw new TypeError("webhook payloadBuilder is invalid");
|
|
191
|
+
}
|
|
192
|
+
const headers = normalizeHeaders(options.headers);
|
|
193
|
+
return {
|
|
194
|
+
url,
|
|
195
|
+
...(headers === undefined ? {} : { headers }),
|
|
196
|
+
timeoutMs,
|
|
197
|
+
maxRetries,
|
|
198
|
+
allowHttpLoopback,
|
|
199
|
+
allowPrivateDestinations: allowPrivateDestinations || (url.protocol === "http:" && allowHttpLoopback),
|
|
200
|
+
...(options.payloadBuilder === undefined ? {} : { payloadBuilder: options.payloadBuilder }),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const resolveWebhookAddress = async (hostname, timeoutMs, signal) => {
|
|
204
|
+
let timer;
|
|
205
|
+
let removeAbortListener = () => undefined;
|
|
206
|
+
try {
|
|
207
|
+
const timeout = new Promise((_, reject) => {
|
|
208
|
+
timer = setTimeout(() => reject(new Error("webhook destination lookup timed out")), timeoutMs);
|
|
209
|
+
});
|
|
210
|
+
const races = [dns.promises.lookup(hostname), timeout];
|
|
211
|
+
if (signal !== undefined) {
|
|
212
|
+
const aborted = new Promise((_, reject) => {
|
|
213
|
+
const onAbort = () => reject(new Error("webhook request timed out"));
|
|
214
|
+
if (signal.aborted) {
|
|
215
|
+
onAbort();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
220
|
+
});
|
|
221
|
+
races.push(aborted);
|
|
222
|
+
}
|
|
223
|
+
return await Promise.race(races);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
if (timer !== undefined) {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
}
|
|
229
|
+
removeAbortListener();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const defaultRequest = async (options, body, timeoutMs) => {
|
|
233
|
+
const resolvedAddress = await resolveWebhookAddress(options.hostname, timeoutMs, options.signal);
|
|
234
|
+
if (!options.allowPrivateDestinations && isPrivateDestination(resolvedAddress.address)) {
|
|
235
|
+
throw privateDestinationError();
|
|
236
|
+
}
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
let settled = false;
|
|
239
|
+
let removeAbortListener = () => undefined;
|
|
240
|
+
const finish = (callback) => {
|
|
241
|
+
if (settled) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
settled = true;
|
|
245
|
+
removeAbortListener();
|
|
246
|
+
callback();
|
|
247
|
+
};
|
|
248
|
+
const { allowPrivateDestinations: _allowPrivateDestinations, ...nodeOptions } = options;
|
|
249
|
+
const request = (options.protocol === "http:" ? http : https).request({
|
|
250
|
+
...nodeOptions,
|
|
251
|
+
lookup: (_hostname, _lookupOptions, callback) => callback(null, resolvedAddress.address, resolvedAddress.family),
|
|
252
|
+
}, (response) => {
|
|
253
|
+
const chunks = [];
|
|
254
|
+
let size = 0;
|
|
255
|
+
response.on("data", (chunk) => {
|
|
256
|
+
size += Buffer.byteLength(chunk);
|
|
257
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
258
|
+
request.destroy();
|
|
259
|
+
finish(() => reject(new Error("webhook response body exceeded limit")));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
263
|
+
});
|
|
264
|
+
response.once("error", (error) => finish(() => reject(error)));
|
|
265
|
+
response.once("end", () => finish(() => resolve({
|
|
266
|
+
statusCode: response.statusCode ?? 0,
|
|
267
|
+
headers: response.headers,
|
|
268
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
269
|
+
})));
|
|
270
|
+
});
|
|
271
|
+
const onAbort = () => {
|
|
272
|
+
request.destroy();
|
|
273
|
+
};
|
|
274
|
+
if (options.signal !== undefined) {
|
|
275
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
276
|
+
removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
|
|
277
|
+
if (options.signal.aborted) {
|
|
278
|
+
onAbort();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
request.once("error", (error) => finish(() => reject(error)));
|
|
282
|
+
request.setTimeout(timeoutMs, () => {
|
|
283
|
+
request.destroy();
|
|
284
|
+
finish(() => reject(new Error("webhook request timed out")));
|
|
285
|
+
});
|
|
286
|
+
request.end(body);
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
const requestWithTimeout = async (request, options, body, timeoutMs) => {
|
|
290
|
+
let timer;
|
|
291
|
+
const controller = new AbortController();
|
|
292
|
+
try {
|
|
293
|
+
const timeout = new Promise((_, reject) => {
|
|
294
|
+
timer = setTimeout(() => {
|
|
295
|
+
controller.abort();
|
|
296
|
+
reject(new Error("webhook request timed out"));
|
|
297
|
+
}, timeoutMs);
|
|
298
|
+
});
|
|
299
|
+
return await Promise.race([
|
|
300
|
+
request({ ...options, signal: controller.signal }, body, timeoutMs),
|
|
301
|
+
timeout,
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
if (timer !== undefined) {
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const deepFreeze = (value) => {
|
|
311
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
Object.freeze(value);
|
|
315
|
+
for (const child of Object.values(value)) {
|
|
316
|
+
deepFreeze(child);
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
};
|
|
320
|
+
const serializeAlert = (alert, payloadBuilder) => {
|
|
321
|
+
const sanitizedAlert = deepFreeze(toBoundedAlertPayload(alert));
|
|
322
|
+
let payload;
|
|
323
|
+
try {
|
|
324
|
+
const custom = payloadBuilder === undefined ? sanitizedAlert : payloadBuilder(sanitizedAlert);
|
|
325
|
+
payload = toBoundedPayload(custom);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
throw new Error("webhook payload builder failed");
|
|
329
|
+
}
|
|
330
|
+
const envelope = {
|
|
331
|
+
version: 1,
|
|
332
|
+
type: "incident.alert",
|
|
333
|
+
sentAt: new Date().toISOString(),
|
|
334
|
+
alert: payload,
|
|
335
|
+
};
|
|
336
|
+
const serialized = JSON.stringify(envelope);
|
|
337
|
+
if (Buffer.byteLength(serialized, "utf8") <= MAX_PAYLOAD_BYTES) {
|
|
338
|
+
return serialized;
|
|
339
|
+
}
|
|
340
|
+
const fallback = JSON.stringify({
|
|
341
|
+
version: 1,
|
|
342
|
+
type: "incident.alert",
|
|
343
|
+
sentAt: new Date().toISOString(),
|
|
344
|
+
alert: {
|
|
345
|
+
title: alert.title.slice(0, 300),
|
|
346
|
+
fingerprint: alert.fingerprint.slice(0, 200),
|
|
347
|
+
severity: alert.severity,
|
|
348
|
+
summary: alert.summary.slice(0, 2_000),
|
|
349
|
+
service: alert.service.slice(0, 200),
|
|
350
|
+
environment: alert.environment.slice(0, 200),
|
|
351
|
+
occurrences: alert.occurrences,
|
|
352
|
+
truncated: true,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
return fallback;
|
|
356
|
+
};
|
|
357
|
+
export async function sendWebhookAlert(options, request = defaultRequest) {
|
|
358
|
+
const normalized = normalizeWebhookOptions(options);
|
|
359
|
+
const body = serializeAlert(options.alert, normalized.payloadBuilder);
|
|
360
|
+
const requestOptions = {
|
|
361
|
+
protocol: normalized.url.protocol,
|
|
362
|
+
hostname: normalized.url.hostname.replace(/^\[|\]$/g, ""),
|
|
363
|
+
...(normalized.url.port === "" ? {} : { port: normalized.url.port }),
|
|
364
|
+
method: "POST",
|
|
365
|
+
path: `${normalized.url.pathname || "/"}${normalized.url.search}`,
|
|
366
|
+
headers: {
|
|
367
|
+
...(normalized.headers ?? {}),
|
|
368
|
+
"content-type": "application/json",
|
|
369
|
+
"content-length": Buffer.byteLength(body, "utf8"),
|
|
370
|
+
},
|
|
371
|
+
allowPrivateDestinations: normalized.allowPrivateDestinations,
|
|
372
|
+
};
|
|
373
|
+
let attempt = 0;
|
|
374
|
+
while (true) {
|
|
375
|
+
const response = await requestWithTimeout(request, requestOptions, body, normalized.timeoutMs);
|
|
376
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (attempt < normalized.maxRetries &&
|
|
380
|
+
(response.statusCode === 429 || response.statusCode >= 500)) {
|
|
381
|
+
attempt += 1;
|
|
382
|
+
await wait(50);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
throw new Error(`Webhook request failed with status ${response.statusCode}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { normalizeWebhookOptions, sendWebhookAlert, } from "./webhook-http.js";
|
|
2
|
+
export function createWebhookNotifier(options, request) {
|
|
3
|
+
const normalized = normalizeWebhookOptions(options);
|
|
4
|
+
const sendOptions = {
|
|
5
|
+
url: normalized.url.toString(),
|
|
6
|
+
...(normalized.headers === undefined ? {} : { headers: normalized.headers }),
|
|
7
|
+
timeoutMs: normalized.timeoutMs,
|
|
8
|
+
maxRetries: normalized.maxRetries,
|
|
9
|
+
allowHttpLoopback: normalized.allowHttpLoopback,
|
|
10
|
+
allowPrivateDestinations: normalized.allowPrivateDestinations,
|
|
11
|
+
...(normalized.payloadBuilder === undefined
|
|
12
|
+
? {}
|
|
13
|
+
: { payloadBuilder: normalized.payloadBuilder }),
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
name: "webhook",
|
|
17
|
+
async send(alert) {
|
|
18
|
+
await sendWebhookAlert({ ...sendOptions, alert }, request);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function webhookNotifier(options) {
|
|
23
|
+
return createWebhookNotifier(options);
|
|
24
|
+
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import type { WotchiConfig, WotchiNotifier } from "./types.js";
|
|
1
|
+
import type { WotchiConfig, WotchiIncidentRule, WotchiLinkTemplates, WotchiNotifier } from "./types.js";
|
|
2
2
|
import { WotchiConfigurationError } from "./errors.js";
|
|
3
3
|
export { WotchiConfigurationError };
|
|
4
4
|
export interface NormalizedWotchiConfig {
|
|
5
5
|
readonly service: string;
|
|
6
6
|
readonly environment: string;
|
|
7
|
+
readonly instance?: string;
|
|
7
8
|
readonly release?: string;
|
|
8
9
|
readonly enabled: boolean;
|
|
10
|
+
readonly filter?: WotchiConfig["filter"];
|
|
11
|
+
readonly fingerprint?: WotchiConfig["fingerprint"];
|
|
12
|
+
readonly beforeSend?: WotchiConfig["beforeSend"];
|
|
13
|
+
readonly links?: Readonly<WotchiLinkTemplates>;
|
|
14
|
+
readonly rules: readonly WotchiIncidentRule[];
|
|
9
15
|
readonly grouping: {
|
|
10
16
|
readonly windowMs: number;
|
|
11
17
|
readonly alertThreshold: number;
|
|
@@ -2,6 +2,10 @@ import type { WotchiDiagnostics } from "./types.js";
|
|
|
2
2
|
export interface DiagnosticsState {
|
|
3
3
|
capturedEvents: number;
|
|
4
4
|
captureFailures: number;
|
|
5
|
+
fingerprintCallbackFailures: number;
|
|
6
|
+
filterFailures: number;
|
|
7
|
+
beforeSendFailures: number;
|
|
8
|
+
eventsSuppressed: number;
|
|
5
9
|
}
|
|
6
10
|
export declare function createDiagnosticsState(): DiagnosticsState;
|
|
7
|
-
export declare function snapshotDiagnostics(state: DiagnosticsState, values: Omit<WotchiDiagnostics, "capturedEvents" | "captureFailures">): Readonly<WotchiDiagnostics>;
|
|
11
|
+
export declare function snapshotDiagnostics(state: DiagnosticsState, values: Omit<WotchiDiagnostics, "capturedEvents" | "captureFailures" | "fingerprintCallbackFailures" | "filterFailures" | "beforeSendFailures" | "eventsSuppressed">): Readonly<WotchiDiagnostics>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const MAX_GROUPS = 10000;
|
|
2
|
+
export declare const MAX_EVENTS_PER_WINDOW = 10000;
|
|
3
|
+
export declare const MAX_PENDING_ALERTS = 10000;
|
|
4
|
+
export declare const MAX_WINDOW_MS: number;
|
|
5
|
+
export declare const MAX_ALERT_THRESHOLD = 1000000;
|
|
6
|
+
export declare const MAX_COOLDOWN_MS: number;
|
|
7
|
+
export declare const MAX_NORMALIZATION_DEPTH = 20;
|
|
8
|
+
export declare const MAX_NORMALIZATION_KEYS = 10000;
|
|
9
|
+
export declare const MAX_NORMALIZATION_STRING_LENGTH = 32768;
|
|
10
|
+
export declare const MAX_NORMALIZATION_STACK_LENGTH = 32768;
|
|
@@ -4,8 +4,12 @@ export interface NotificationQueueOptions {
|
|
|
4
4
|
concurrency: 1;
|
|
5
5
|
onNotifierError?: (error: unknown, notifier: WotchiNotifier) => void;
|
|
6
6
|
}
|
|
7
|
+
export interface NotificationJobResult {
|
|
8
|
+
notifierFailures: number;
|
|
9
|
+
sent: number;
|
|
10
|
+
}
|
|
7
11
|
export interface NotificationQueue {
|
|
8
|
-
enqueue(alert: IncidentAlert, notifiers: readonly WotchiNotifier[]): boolean;
|
|
12
|
+
enqueue(alert: IncidentAlert, notifiers: readonly WotchiNotifier[], onComplete?: (result: NotificationJobResult) => void): boolean;
|
|
9
13
|
flush(timeoutMs?: number): Promise<void>;
|
|
10
14
|
pending(): number;
|
|
11
15
|
alertsQueued(): number;
|
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
export type IncidentSeverity = "low" | "medium" | "high" | "critical";
|
|
2
2
|
export type WotchiEventKind = "error" | "manual" | "process-monitor";
|
|
3
|
+
export interface WotchiTraceContext {
|
|
4
|
+
traceId?: string;
|
|
5
|
+
spanId?: string;
|
|
6
|
+
}
|
|
3
7
|
export interface WotchiRequestContext extends Record<string, unknown> {
|
|
4
8
|
method?: string;
|
|
5
9
|
route?: string;
|
|
6
10
|
statusCode?: number;
|
|
7
11
|
requestId?: string;
|
|
12
|
+
correlationId?: string;
|
|
13
|
+
trace?: WotchiTraceContext;
|
|
14
|
+
}
|
|
15
|
+
export type WotchiTags = Readonly<Record<string, string>>;
|
|
16
|
+
export interface WotchiLinkTemplates {
|
|
17
|
+
log?: string;
|
|
18
|
+
trace?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface WotchiLinks {
|
|
21
|
+
log?: string;
|
|
22
|
+
trace?: string;
|
|
23
|
+
}
|
|
24
|
+
export type WotchiFingerprintCallback = (event: Readonly<SafeErrorEvent>) => string | undefined;
|
|
25
|
+
export type WotchiFingerprintOverride = string | WotchiFingerprintCallback;
|
|
26
|
+
export type WotchiEventFilter = (event: Readonly<SafeErrorEvent>) => boolean;
|
|
27
|
+
export type WotchiBeforeSend = (alert: Readonly<IncidentAlert>) => IncidentAlert | null | undefined;
|
|
28
|
+
export interface WotchiCaptureOptions {
|
|
29
|
+
fingerprint?: WotchiFingerprintOverride;
|
|
30
|
+
severity?: IncidentSeverity;
|
|
31
|
+
alertThreshold?: number;
|
|
32
|
+
request?: WotchiRequestContext;
|
|
33
|
+
trace?: WotchiTraceContext;
|
|
34
|
+
correlationId?: string;
|
|
35
|
+
operation?: string;
|
|
36
|
+
job?: string;
|
|
37
|
+
tags?: Record<string, unknown>;
|
|
8
38
|
}
|
|
9
39
|
export interface WotchiEventInput {
|
|
10
40
|
level: "error";
|
|
@@ -12,16 +42,28 @@ export interface WotchiEventInput {
|
|
|
12
42
|
message: string;
|
|
13
43
|
error?: unknown;
|
|
14
44
|
alertThreshold?: number;
|
|
45
|
+
severity?: IncidentSeverity;
|
|
46
|
+
fingerprint?: WotchiFingerprintOverride;
|
|
15
47
|
metadata?: Record<string, unknown>;
|
|
16
48
|
context?: Record<string, unknown>;
|
|
17
49
|
request?: WotchiRequestContext;
|
|
50
|
+
trace?: WotchiTraceContext;
|
|
51
|
+
correlationId?: string;
|
|
52
|
+
operation?: string;
|
|
53
|
+
job?: string;
|
|
54
|
+
tags?: Record<string, unknown>;
|
|
18
55
|
}
|
|
19
56
|
export interface SafeErrorEvent {
|
|
20
57
|
id: string;
|
|
21
58
|
timestamp: string;
|
|
22
59
|
service: string;
|
|
23
60
|
environment: string;
|
|
61
|
+
instance?: string;
|
|
24
62
|
release?: string;
|
|
63
|
+
correlationId?: string;
|
|
64
|
+
operation?: string;
|
|
65
|
+
job?: string;
|
|
66
|
+
tags?: WotchiTags;
|
|
25
67
|
error: {
|
|
26
68
|
name: string;
|
|
27
69
|
message: string;
|
|
@@ -29,6 +71,7 @@ export interface SafeErrorEvent {
|
|
|
29
71
|
code?: string;
|
|
30
72
|
};
|
|
31
73
|
request?: WotchiRequestContext;
|
|
74
|
+
trace?: WotchiTraceContext;
|
|
32
75
|
context?: Record<string, unknown>;
|
|
33
76
|
}
|
|
34
77
|
export interface IncidentGroup {
|
|
@@ -53,6 +96,19 @@ export interface IncidentAlert {
|
|
|
53
96
|
occurrences: number;
|
|
54
97
|
service: string;
|
|
55
98
|
environment: string;
|
|
99
|
+
instance?: string;
|
|
100
|
+
release?: string;
|
|
101
|
+
correlationId?: string;
|
|
102
|
+
operation?: string;
|
|
103
|
+
job?: string;
|
|
104
|
+
tags?: WotchiTags;
|
|
105
|
+
error?: SafeErrorEvent["error"] & {
|
|
106
|
+
applicationFrame?: string;
|
|
107
|
+
};
|
|
108
|
+
request?: WotchiRequestContext;
|
|
109
|
+
trace?: WotchiTraceContext;
|
|
110
|
+
context?: Record<string, unknown>;
|
|
111
|
+
links?: WotchiLinks;
|
|
56
112
|
}
|
|
57
113
|
export interface WotchiNotifier {
|
|
58
114
|
readonly name: string;
|
|
@@ -67,11 +123,33 @@ export interface TelegramNotifierOptions {
|
|
|
67
123
|
chatId: string;
|
|
68
124
|
timeoutMs?: number;
|
|
69
125
|
}
|
|
126
|
+
export interface WebhookNotifierOptions {
|
|
127
|
+
url: string;
|
|
128
|
+
headers?: Record<string, string>;
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
maxRetries?: number;
|
|
131
|
+
allowHttpLoopback?: boolean;
|
|
132
|
+
allowPrivateDestinations?: boolean;
|
|
133
|
+
payloadBuilder?: (alert: Readonly<IncidentAlert>) => unknown;
|
|
134
|
+
}
|
|
135
|
+
export interface WotchiIncidentRule {
|
|
136
|
+
environment?: string;
|
|
137
|
+
route?: string;
|
|
138
|
+
ignore?: boolean;
|
|
139
|
+
alertThreshold?: number;
|
|
140
|
+
severity?: IncidentSeverity;
|
|
141
|
+
}
|
|
70
142
|
export interface WotchiConfig {
|
|
71
143
|
service: string;
|
|
72
144
|
environment: string;
|
|
145
|
+
instance?: string;
|
|
73
146
|
release?: string;
|
|
74
147
|
enabled?: boolean;
|
|
148
|
+
filter?: WotchiEventFilter;
|
|
149
|
+
fingerprint?: WotchiFingerprintCallback;
|
|
150
|
+
beforeSend?: WotchiBeforeSend;
|
|
151
|
+
links?: WotchiLinkTemplates;
|
|
152
|
+
rules?: readonly WotchiIncidentRule[];
|
|
75
153
|
grouping?: {
|
|
76
154
|
windowMs?: number;
|
|
77
155
|
alertThreshold?: number;
|
|
@@ -100,12 +178,28 @@ export interface WotchiDiagnostics {
|
|
|
100
178
|
alertsDropped: number;
|
|
101
179
|
alertsSent: number;
|
|
102
180
|
notifierFailures: number;
|
|
181
|
+
fingerprintCallbackFailures: number;
|
|
182
|
+
filterFailures: number;
|
|
183
|
+
beforeSendFailures: number;
|
|
184
|
+
eventsSuppressed: number;
|
|
103
185
|
activeGroups: number;
|
|
104
186
|
pendingAlerts: number;
|
|
105
187
|
}
|
|
188
|
+
export type WotchiTestAlertStatus = "sent" | "queue-full" | "timeout" | "notifier-failed";
|
|
189
|
+
export interface WotchiTestAlertResult {
|
|
190
|
+
status: WotchiTestAlertStatus;
|
|
191
|
+
configurationAccepted: true;
|
|
192
|
+
queued: boolean;
|
|
193
|
+
flushed: boolean;
|
|
194
|
+
delivered: boolean;
|
|
195
|
+
notifierFailures: number;
|
|
196
|
+
diagnostics: Readonly<WotchiDiagnostics>;
|
|
197
|
+
error?: string;
|
|
198
|
+
}
|
|
106
199
|
export interface WotchiClient {
|
|
107
|
-
captureException(error: unknown, context?: Record<string, unknown
|
|
200
|
+
captureException(error: unknown, context?: Record<string, unknown>, options?: WotchiCaptureOptions): void;
|
|
108
201
|
captureEvent(event: WotchiEventInput): void;
|
|
202
|
+
testAlert(): Promise<WotchiTestAlertResult>;
|
|
109
203
|
flush(timeoutMs?: number): Promise<void>;
|
|
110
204
|
getDiagnostics(): Readonly<WotchiDiagnostics>;
|
|
111
205
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { createWotchi } from "./core/client.js";
|
|
|
2
2
|
import { registerWotchiProcessMonitor } from "./core/process-monitor.js";
|
|
3
3
|
import { consoleNotifier } from "./notifiers/console.js";
|
|
4
4
|
import { telegramNotifier } from "./notifiers/telegram.js";
|
|
5
|
+
import { webhookNotifier } from "./notifiers/webhook.js";
|
|
5
6
|
export { WotchiConfigurationError } from "./core/errors.js";
|
|
6
7
|
export type { ProcessMonitorHandle } from "./core/process-monitor.js";
|
|
7
8
|
export type { NormalizedWotchiConfig } from "./core/config.js";
|
|
8
|
-
export type { ConsoleNotifierOptions, IncidentAlert, IncidentGroup, IncidentSeverity, SafeErrorEvent, TelegramNotifierOptions, WotchiClient, WotchiConfig, WotchiDiagnostics, WotchiEventInput, WotchiEventKind, WotchiNotifier, WotchiRequestContext, } from "./core/types.js";
|
|
9
|
-
export {
|
|
9
|
+
export type { ConsoleNotifierOptions, IncidentAlert, IncidentGroup, IncidentSeverity, SafeErrorEvent, TelegramNotifierOptions, WebhookNotifierOptions, WotchiBeforeSend, WotchiCaptureOptions, WotchiClient, WotchiConfig, WotchiDiagnostics, WotchiEventFilter, WotchiEventInput, WotchiEventKind, WotchiFingerprintOverride, WotchiFingerprintCallback, WotchiIncidentRule, WotchiLinkTemplates, WotchiLinks, WotchiNotifier, WotchiRequestContext, WotchiTags, WotchiTestAlertResult, WotchiTestAlertStatus, WotchiTraceContext, } from "./core/types.js";
|
|
10
|
+
export type { WebhookRequestFunction, WebhookRequestOptions, WebhookResponse, } from "./notifiers/webhook-http.js";
|
|
11
|
+
export { consoleNotifier, createWotchi, registerWotchiProcessMonitor, telegramNotifier, webhookNotifier, };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { WotchiClient } from "../../core/types.js";
|
|
2
2
|
import { createExpressErrorHandler } from "./error-handler.js";
|
|
3
3
|
import type { ExpressWotchiOptions } from "./request-context.js";
|
|
4
|
-
export { consoleNotifier, createWotchi, telegramNotifier } from "../../index.js";
|
|
5
|
-
export type { ConsoleNotifierOptions, IncidentAlert, IncidentGroup, IncidentSeverity, SafeErrorEvent, TelegramNotifierOptions, WotchiClient, WotchiConfig, WotchiDiagnostics, WotchiEventInput, WotchiNotifier, WotchiRequestContext, } from "../../index.js";
|
|
4
|
+
export { consoleNotifier, createWotchi, telegramNotifier, webhookNotifier } from "../../index.js";
|
|
5
|
+
export type { ConsoleNotifierOptions, IncidentAlert, IncidentGroup, IncidentSeverity, SafeErrorEvent, TelegramNotifierOptions, WebhookNotifierOptions, WotchiBeforeSend, WotchiCaptureOptions, WotchiClient, WotchiConfig, WotchiDiagnostics, WotchiEventFilter, WotchiEventInput, WotchiFingerprintCallback, WotchiFingerprintOverride, WotchiIncidentRule, WotchiLinkTemplates, WotchiLinks, WotchiNotifier, WotchiRequestContext, WotchiTags, WotchiTestAlertResult, WotchiTestAlertStatus, WotchiTraceContext, } from "../../index.js";
|
|
6
6
|
export type { ExpressWotchiOptions } from "./request-context.js";
|
|
7
7
|
export type { WotchiStatusClass, WotchiStatusObserverOptions } from "./status-observer.js";
|
|
8
8
|
export { wotchiStatusObserver } from "./status-observer.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { consoleNotifier, createWotchi, telegramNotifier } from "../../index.js";
|
|
1
|
+
export { consoleNotifier, createWotchi, telegramNotifier, webhookNotifier } from "../../index.js";
|
|
2
2
|
export { registerWotchiNest, registerWotchiNestStatusObserver } from "./register.js";
|
|
3
|
-
export type { ConsoleNotifierOptions, IncidentAlert, IncidentGroup, IncidentSeverity, SafeErrorEvent, TelegramNotifierOptions, WotchiClient, WotchiConfig, WotchiDiagnostics, WotchiEventInput, WotchiNotifier, WotchiRequestContext, } from "../../index.js";
|
|
3
|
+
export type { ConsoleNotifierOptions, IncidentAlert, IncidentGroup, IncidentSeverity, SafeErrorEvent, TelegramNotifierOptions, WebhookNotifierOptions, WotchiBeforeSend, WotchiCaptureOptions, WotchiClient, WotchiConfig, WotchiDiagnostics, WotchiEventFilter, WotchiEventInput, WotchiFingerprintCallback, WotchiFingerprintOverride, WotchiIncidentRule, WotchiLinkTemplates, WotchiLinks, WotchiNotifier, WotchiRequestContext, WotchiTags, WotchiTestAlertResult, WotchiTestAlertStatus, WotchiTraceContext, } from "../../index.js";
|
|
4
4
|
export type { NestWotchiApplication, NestWotchiOptions } from "./register.js";
|
|
5
5
|
export type { WotchiStatusClass, WotchiStatusObserverOptions } from "../express/status-observer.js";
|