@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
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# Wotchi
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Low-noise error alerts for Node.js services.
|
|
4
4
|
|
|
5
|
-
> **Status:** Public beta (`0.1.0-beta.
|
|
5
|
+
> **Status:** Public beta (`0.1.0-beta.5`). Install with the `beta` tag; the API may evolve before the first stable release.
|
|
6
6
|
|
|
7
7
|
[](https://github.com/FutureWindAI/Wotchi/actions/workflows/ci.yml)
|
|
8
8
|
[](https://github.com/FutureWindAI/Wotchi/actions/workflows/codeql.yml)
|
|
9
9
|
[](https://www.npmjs.com/package/@futurewindai/wotchi)
|
|
10
10
|
[](LICENSE)
|
|
11
|
-
[](https://nodejs.org/en/about/previous-releases)
|
|
12
12
|
|
|
13
|
-
Wotchi captures application errors in-process, removes sensitive values, groups repeated failures, and delivers bounded console or
|
|
13
|
+
Wotchi captures application errors in-process, removes sensitive values, groups repeated failures, and delivers bounded console, Telegram, or HTTPS webhook alerts without changing framework response handling. It is a signal-conditioning layer, not a replacement for a full observability platform.
|
|
14
14
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
@@ -34,6 +34,8 @@ await wotchi.flush();
|
|
|
34
34
|
|
|
35
35
|
The example uses a threshold of one so the alert is visible immediately. The default policy groups three matching errors in one minute and suppresses duplicate alerts during the cooldown. Capture is synchronous; call `flush()` when the host needs to wait for notifier work.
|
|
36
36
|
|
|
37
|
+
To validate a notifier without deliberately throwing an application error, call `const result = await wotchi.testAlert()` in a controlled setup. The structured result reports whether the alert was queued, flushed, delivered, or rejected by a notifier; configuration errors still throw during `createWotchi`.
|
|
38
|
+
|
|
37
39
|
Example console output:
|
|
38
40
|
|
|
39
41
|
```text
|
|
@@ -54,26 +56,27 @@ notifier.
|
|
|
54
56
|
|
|
55
57
|
## What you get
|
|
56
58
|
|
|
57
|
-
| Capability | Result
|
|
58
|
-
| ----------------------------------------- |
|
|
59
|
-
| Bounded capture and queueing | Repeated failures cannot create unbounded in-memory work.
|
|
60
|
-
| Redaction before processing | Sensitive values are removed before grouping, logging, or transmission.
|
|
61
|
-
| Grouping and cooldowns | Repeated failures produce a small number of useful alerts.
|
|
62
|
-
| Express 4/5 and NestJS 10/11 adapters | Errors are observed while the framework keeps response ownership.
|
|
63
|
-
| ESM, CommonJS, and TypeScript types | Use the package with common Node.js module setups.
|
|
64
|
-
| Console and optional Telegram notifiers | Start locally or self-host delivery without a Wotchi control plane.
|
|
65
|
-
|
|
|
59
|
+
| Capability | Result |
|
|
60
|
+
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| Bounded capture and queueing | Repeated failures cannot create unbounded in-memory work. |
|
|
62
|
+
| Redaction before processing | Sensitive values are removed before grouping, logging, or transmission. |
|
|
63
|
+
| Grouping and cooldowns | Repeated failures produce a small number of useful alerts. |
|
|
64
|
+
| Express 4/5 and NestJS 10/11 adapters | Errors are observed while the framework keeps response ownership. |
|
|
65
|
+
| ESM, CommonJS, and TypeScript types | Use the package with common Node.js module setups. |
|
|
66
|
+
| Console and optional Telegram notifiers | Start locally or self-host delivery without a Wotchi control plane. |
|
|
67
|
+
| Generic HTTPS webhook notifier | Route bounded JSON alerts to an existing internal alert destination. |
|
|
68
|
+
| Actionable context and trace passthrough | Include route, release, request/correlation IDs, operation/job, safe tags, links, and existing trace/span IDs. |
|
|
69
|
+
| Optional status observation and JSON logs | Observe direct `401`/`403`/`429`/`5xx` responses and emit collector-friendly JSON. |
|
|
66
70
|
|
|
67
71
|
## How it works
|
|
68
72
|
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
E --> G[Telegram]
|
|
73
|
+
```text
|
|
74
|
+
Application error
|
|
75
|
+
-> normalize and redact
|
|
76
|
+
-> stable fingerprint and optional user rule
|
|
77
|
+
-> threshold and cooldown
|
|
78
|
+
-> bounded notification queue
|
|
79
|
+
-> console, Telegram, or HTTPS webhook
|
|
77
80
|
```
|
|
78
81
|
|
|
79
82
|
The same bounded capture path can be called from HTTP handlers, background workers, and queue
|
|
@@ -107,7 +110,7 @@ import { registerWotchiNest } from "@futurewindai/wotchi/nest";
|
|
|
107
110
|
registerWotchiNest(app, wotchi);
|
|
108
111
|
```
|
|
109
112
|
|
|
110
|
-
The package
|
|
113
|
+
The package keeps compatibility with Node.js `>=18.18.0` and is tested across Node.js 18–26. Node.js 22 or 24 LTS is recommended for production. Express and NestJS adapters are optional subpath integrations, so applications only load the framework adapter they use.
|
|
111
114
|
|
|
112
115
|
## Telegram alerts
|
|
113
116
|
|
|
@@ -122,15 +125,89 @@ const wotchi = createWotchi({
|
|
|
122
125
|
notifiers: [
|
|
123
126
|
consoleNotifier(),
|
|
124
127
|
telegramNotifier({
|
|
125
|
-
botToken:
|
|
126
|
-
chatId:
|
|
128
|
+
botToken: requiredEnv("WOTCHI_TELEGRAM_BOT_TOKEN"),
|
|
129
|
+
chatId: requiredEnv("WOTCHI_TELEGRAM_CHAT_ID"),
|
|
127
130
|
}),
|
|
128
131
|
],
|
|
129
132
|
});
|
|
133
|
+
|
|
134
|
+
function requiredEnv(name: string): string {
|
|
135
|
+
const value = process.env[name];
|
|
136
|
+
if (value === undefined || value.trim() === "") {
|
|
137
|
+
throw new Error(`${name} must be configured before enabling Telegram`);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
130
141
|
```
|
|
131
142
|
|
|
132
143
|
Wotchi does not ship a shared bot token. Delivery is queued outside the request path and sends only the sanitized incident alert. See [configuration](docs/CONFIGURATION.md) for notifier and security options.
|
|
133
144
|
|
|
145
|
+
## HTTPS webhook alerts
|
|
146
|
+
|
|
147
|
+
Use the generic webhook when an existing internal alerting or automation endpoint accepts JSON. Wotchi requires HTTPS by default, bounds headers and payloads, emits a versioned `{ version: 1, type: "incident.alert", sentAt, alert }` envelope, times out delivery, and retries one `429`/`5xx` response. Explicit loopback HTTP can be enabled for a local collector with `allowHttpLoopback: true`:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { createWotchi, webhookNotifier } from "@futurewindai/wotchi";
|
|
151
|
+
|
|
152
|
+
const wotchi = createWotchi({
|
|
153
|
+
service: "orders-api",
|
|
154
|
+
environment: "production",
|
|
155
|
+
notifiers: [
|
|
156
|
+
webhookNotifier({
|
|
157
|
+
url: requiredEnv("WOTCHI_WEBHOOK_URL"),
|
|
158
|
+
headers: { Authorization: requiredEnv("WOTCHI_WEBHOOK_AUTH") },
|
|
159
|
+
payloadBuilder: (alert) => ({
|
|
160
|
+
incident: alert.fingerprint,
|
|
161
|
+
summary: alert.summary,
|
|
162
|
+
}),
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function requiredEnv(name: string): string {
|
|
168
|
+
const value = process.env[name];
|
|
169
|
+
if (value === undefined || value.trim() === "") {
|
|
170
|
+
throw new Error(`${name} must be configured before enabling the webhook`);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Keep the URL and authentication header outside source control. `payloadBuilder` receives a frozen, sanitized alert and its output is redacted and bounded before transport; it is not a hosted Wotchi collector.
|
|
177
|
+
|
|
178
|
+
## Context, filtering, and existing traces
|
|
179
|
+
|
|
180
|
+
Pass safe operational context and an existing OpenTelemetry trace/span ID explicitly; Wotchi does not install an OpenTelemetry SDK:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
wotchi.captureEvent({
|
|
184
|
+
level: "error",
|
|
185
|
+
message: "payment provider failed",
|
|
186
|
+
error,
|
|
187
|
+
request: {
|
|
188
|
+
method: "POST",
|
|
189
|
+
route: "/orders/:id",
|
|
190
|
+
requestId: "req-123",
|
|
191
|
+
correlationId: "corr-456",
|
|
192
|
+
trace: { traceId, spanId },
|
|
193
|
+
},
|
|
194
|
+
operation: "orders.pay",
|
|
195
|
+
job: "payment-retry",
|
|
196
|
+
tags: { component: "checkout" },
|
|
197
|
+
context: { provider: "stripe" },
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Use `filter`, `fingerprint`, `beforeSend`, and bounded exact-match `rules` for service-specific noise and grouping controls. `filter` and fingerprint callbacks receive frozen, normalized, redacted events. `beforeSend` receives a frozen, sanitized `IncidentAlert`; return `null` to suppress it or a bounded alert to transform it. Hook failures are isolated and counted in diagnostics. Optional `links.log` and `links.trace` templates can use placeholders such as `{{service}}`, `{{requestId}}`, and `{{traceId}}`.
|
|
202
|
+
|
|
203
|
+
## Deployment boundaries
|
|
204
|
+
|
|
205
|
+
Wotchi keeps grouping and cooldown state in one process. Replicas have independent state, restarts
|
|
206
|
+
reset groups, and a serverless instance can terminate before asynchronous delivery completes. An
|
|
207
|
+
in-process SDK cannot reliably detect an OOM kill, frozen event loop, host failure, or unavailable
|
|
208
|
+
network. Pair it with an external uptime monitor and keep graceful shutdown explicit; see the
|
|
209
|
+
[production recipe](examples/production-recipe/README.md).
|
|
210
|
+
|
|
134
211
|
## Process monitoring
|
|
135
212
|
|
|
136
213
|
Crash observation is opt-in:
|
|
@@ -147,7 +224,7 @@ const monitor = registerWotchiProcessMonitor(wotchi);
|
|
|
147
224
|
- A full observability, APM, or log-management platform.
|
|
148
225
|
- A hosted dashboard, collector, or persistent incident database.
|
|
149
226
|
- An AI-generated incident-summary service in this release.
|
|
150
|
-
- A Slack, Discord, email, or
|
|
227
|
+
- A Slack, Discord, email, or full incident-workflow platform.
|
|
151
228
|
- A Docker, Kubernetes, or Helm collector bundled into the npm SDK.
|
|
152
229
|
- An automatic-remediation system.
|
|
153
230
|
|
package/dist/cjs/core/client.js
CHANGED
|
@@ -13,6 +13,34 @@ const redact_js_1 = require("./redact.js");
|
|
|
13
13
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
14
|
const asString = (value) => typeof value === "string" ? value : undefined;
|
|
15
15
|
const asStatusCode = (value) => typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
|
|
16
|
+
const deepFreeze = (value) => {
|
|
17
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
Object.freeze(value);
|
|
21
|
+
for (const child of Object.values(value)) {
|
|
22
|
+
deepFreeze(child);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
const safeTrace = (value, privacy) => {
|
|
27
|
+
if (value === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const redacted = (0, redact_js_1.redactValue)(value, privacy);
|
|
31
|
+
if (!isRecord(redacted)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const traceId = asString(redacted.traceId);
|
|
35
|
+
const spanId = asString(redacted.spanId);
|
|
36
|
+
if (traceId === undefined && spanId === undefined) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...(traceId === undefined ? {} : { traceId: traceId.slice(0, 128) }),
|
|
41
|
+
...(spanId === undefined ? {} : { spanId: spanId.slice(0, 128) }),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
16
44
|
const safeRecord = (value, privacy) => {
|
|
17
45
|
if (value === undefined) {
|
|
18
46
|
return undefined;
|
|
@@ -42,20 +70,48 @@ const safeRequest = (request, privacy) => {
|
|
|
42
70
|
const method = asString(redacted.method);
|
|
43
71
|
const route = asString(redacted.route);
|
|
44
72
|
const requestId = asString(redacted.requestId);
|
|
73
|
+
const correlationId = asString(redacted.correlationId);
|
|
45
74
|
const statusCode = asStatusCode(redacted.statusCode);
|
|
75
|
+
const trace = safeTrace(redacted.trace, privacy);
|
|
46
76
|
if (method === undefined &&
|
|
47
77
|
route === undefined &&
|
|
48
78
|
requestId === undefined &&
|
|
49
|
-
|
|
79
|
+
correlationId === undefined &&
|
|
80
|
+
statusCode === undefined &&
|
|
81
|
+
trace === undefined) {
|
|
50
82
|
return undefined;
|
|
51
83
|
}
|
|
52
84
|
return {
|
|
53
85
|
...(method === undefined ? {} : { method }),
|
|
54
86
|
...(route === undefined ? {} : { route }),
|
|
55
87
|
...(requestId === undefined ? {} : { requestId }),
|
|
88
|
+
...(correlationId === undefined ? {} : { correlationId }),
|
|
56
89
|
...(statusCode === undefined ? {} : { statusCode }),
|
|
90
|
+
...(trace === undefined ? {} : { trace }),
|
|
57
91
|
};
|
|
58
92
|
};
|
|
93
|
+
const safeTags = (value, privacy) => {
|
|
94
|
+
const redacted = (0, redact_js_1.redactValue)(value, { ...privacy, maxKeys: Math.min(privacy.maxKeys, 50) });
|
|
95
|
+
if (!isRecord(redacted)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const tags = {};
|
|
99
|
+
for (const [key, tagValue] of Object.entries(redacted)) {
|
|
100
|
+
if (key.length > 64 || typeof tagValue !== "string") {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
tags[key] = tagValue.slice(0, 200);
|
|
104
|
+
}
|
|
105
|
+
return Object.keys(tags).length === 0 ? undefined : tags;
|
|
106
|
+
};
|
|
107
|
+
const safeShortString = (value, privacy, maxLength) => {
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const redacted = (0, redact_js_1.redactValue)(value, { ...privacy, maxStringLength: maxLength });
|
|
112
|
+
const text = asString(redacted);
|
|
113
|
+
return text === undefined ? undefined : text.slice(0, maxLength);
|
|
114
|
+
};
|
|
59
115
|
const safeMessage = (value, privacy) => {
|
|
60
116
|
const normalized = (0, normalize_js_1.normalizeUnknown)(value, privacy);
|
|
61
117
|
return typeof normalized === "string" ? normalized : "[unreadable value]";
|
|
@@ -69,6 +125,150 @@ const normalizeEventAlertThreshold = (value) => {
|
|
|
69
125
|
}
|
|
70
126
|
return value;
|
|
71
127
|
};
|
|
128
|
+
const safeErrorFromValue = (value, privacy) => {
|
|
129
|
+
const redacted = (0, redact_js_1.redactValue)(value, privacy);
|
|
130
|
+
if (!isRecord(redacted)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const name = asString(redacted.name);
|
|
134
|
+
const message = asString(redacted.message);
|
|
135
|
+
if (name === undefined || message === undefined) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const stack = asString(redacted.stack);
|
|
139
|
+
const code = asString(redacted.code);
|
|
140
|
+
const applicationFrame = asString(redacted.applicationFrame);
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
message,
|
|
144
|
+
...(stack === undefined ? {} : { stack }),
|
|
145
|
+
...(code === undefined ? {} : { code }),
|
|
146
|
+
...(applicationFrame === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { applicationFrame: applicationFrame.slice(0, 1_000) }),
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
const safeAlertFromValue = (value, fallback, privacy) => {
|
|
152
|
+
const redacted = (0, redact_js_1.redactValue)(value, privacy);
|
|
153
|
+
if (!isRecord(redacted)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
const title = asString(redacted.title);
|
|
157
|
+
const fingerprint = asString(redacted.fingerprint);
|
|
158
|
+
const summary = asString(redacted.summary);
|
|
159
|
+
const severity = asString(redacted.severity);
|
|
160
|
+
const firstSeenAt = asString(redacted.firstSeenAt);
|
|
161
|
+
const lastSeenAt = asString(redacted.lastSeenAt);
|
|
162
|
+
const service = asString(redacted.service);
|
|
163
|
+
const environment = asString(redacted.environment);
|
|
164
|
+
const occurrences = asStatusCode(redacted.occurrences);
|
|
165
|
+
if (title === undefined ||
|
|
166
|
+
fingerprint === undefined ||
|
|
167
|
+
summary === undefined ||
|
|
168
|
+
firstSeenAt === undefined ||
|
|
169
|
+
lastSeenAt === undefined ||
|
|
170
|
+
service === undefined ||
|
|
171
|
+
environment === undefined ||
|
|
172
|
+
occurrences === undefined ||
|
|
173
|
+
(severity !== "low" && severity !== "medium" && severity !== "high" && severity !== "critical")) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
const rawActions = redacted.suggestedActions;
|
|
177
|
+
if (!Array.isArray(rawActions)) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const suggestedActions = rawActions
|
|
181
|
+
.filter((action) => typeof action === "string")
|
|
182
|
+
.slice(0, 5)
|
|
183
|
+
.map((action) => action.slice(0, 600));
|
|
184
|
+
const error = safeErrorFromValue(redacted.error, privacy);
|
|
185
|
+
const request = safeRequest(redacted.request, privacy);
|
|
186
|
+
const trace = safeTrace(redacted.trace, privacy);
|
|
187
|
+
const context = safeRecord(redacted.context, privacy);
|
|
188
|
+
const tags = safeTags(redacted.tags, privacy);
|
|
189
|
+
const linksValue = safeRecord(redacted.links, privacy);
|
|
190
|
+
const logLink = linksValue === undefined ? undefined : asString(linksValue.log);
|
|
191
|
+
const traceLink = linksValue === undefined ? undefined : asString(linksValue.trace);
|
|
192
|
+
const links = linksValue
|
|
193
|
+
? {
|
|
194
|
+
...(logLink === undefined ? {} : { log: logLink.slice(0, 2_048) }),
|
|
195
|
+
...(traceLink === undefined ? {} : { trace: traceLink.slice(0, 2_048) }),
|
|
196
|
+
}
|
|
197
|
+
: undefined;
|
|
198
|
+
const instance = asString(redacted.instance);
|
|
199
|
+
const release = asString(redacted.release);
|
|
200
|
+
const correlationId = asString(redacted.correlationId);
|
|
201
|
+
const operation = asString(redacted.operation);
|
|
202
|
+
const job = asString(redacted.job);
|
|
203
|
+
return deepFreeze({
|
|
204
|
+
id: asString(redacted.id) ?? fallback.id,
|
|
205
|
+
fingerprint: fingerprint.slice(0, 200),
|
|
206
|
+
title: title.slice(0, 300),
|
|
207
|
+
severity,
|
|
208
|
+
summary: summary.slice(0, 2_000),
|
|
209
|
+
suggestedActions,
|
|
210
|
+
firstSeenAt: firstSeenAt.slice(0, 100),
|
|
211
|
+
lastSeenAt: lastSeenAt.slice(0, 100),
|
|
212
|
+
occurrences: Math.max(0, Math.min(occurrences, Number.MAX_SAFE_INTEGER)),
|
|
213
|
+
service: service.slice(0, 300),
|
|
214
|
+
environment: environment.slice(0, 300),
|
|
215
|
+
...(instance === undefined ? {} : { instance: instance.slice(0, 200) }),
|
|
216
|
+
...(release === undefined ? {} : { release: release.slice(0, 200) }),
|
|
217
|
+
...(correlationId === undefined ? {} : { correlationId: correlationId.slice(0, 200) }),
|
|
218
|
+
...(operation === undefined ? {} : { operation: operation.slice(0, 200) }),
|
|
219
|
+
...(job === undefined ? {} : { job: job.slice(0, 200) }),
|
|
220
|
+
...(tags === undefined ? {} : { tags }),
|
|
221
|
+
...(error === undefined ? {} : { error }),
|
|
222
|
+
...(request === undefined ? {} : { request }),
|
|
223
|
+
...(trace === undefined ? {} : { trace }),
|
|
224
|
+
...(context === undefined ? {} : { context }),
|
|
225
|
+
...(links === undefined || Object.keys(links).length === 0 ? {} : { links }),
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
const normalizeFingerprintOverride = (override, event) => {
|
|
229
|
+
if (override === undefined) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
const value = typeof override === "function" ? override(event) : override;
|
|
233
|
+
if (value === undefined) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > 200) {
|
|
237
|
+
throw new TypeError("fingerprint must be a non-empty string no longer than 200 characters");
|
|
238
|
+
}
|
|
239
|
+
return value.trim();
|
|
240
|
+
};
|
|
241
|
+
const renderLinks = (templates, event, fingerprint) => {
|
|
242
|
+
if (templates === undefined) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const values = {
|
|
246
|
+
service: event.service,
|
|
247
|
+
environment: event.environment,
|
|
248
|
+
release: event.release ?? "",
|
|
249
|
+
instance: event.instance ?? "",
|
|
250
|
+
fingerprint,
|
|
251
|
+
requestId: event.request?.requestId ?? "",
|
|
252
|
+
correlationId: event.correlationId ?? event.request?.correlationId ?? "",
|
|
253
|
+
traceId: event.trace?.traceId ?? event.request?.trace?.traceId ?? "",
|
|
254
|
+
spanId: event.trace?.spanId ?? event.request?.trace?.spanId ?? "",
|
|
255
|
+
route: event.request?.route ?? "",
|
|
256
|
+
statusCode: event.request?.statusCode === undefined ? "" : String(event.request.statusCode),
|
|
257
|
+
};
|
|
258
|
+
const render = (template) => {
|
|
259
|
+
const expanded = template.replace(/\{\{([^{}]+)\}\}/g, (_match, key) => encodeURIComponent(values[key] ?? ""));
|
|
260
|
+
const redacted = (0, redact_js_1.redactValue)(expanded, { maxStringLength: 2_048 });
|
|
261
|
+
return typeof redacted === "string" ? redacted.slice(0, 2_048) : "";
|
|
262
|
+
};
|
|
263
|
+
const result = {};
|
|
264
|
+
if (templates.log !== undefined) {
|
|
265
|
+
result.log = render(templates.log);
|
|
266
|
+
}
|
|
267
|
+
if (templates.trace !== undefined && (values.traceId !== "" || values.spanId !== "")) {
|
|
268
|
+
result.trace = render(templates.trace);
|
|
269
|
+
}
|
|
270
|
+
return Object.keys(result).length === 0 ? undefined : result;
|
|
271
|
+
};
|
|
72
272
|
function createWotchi(config) {
|
|
73
273
|
const normalized = (0, config_js_1.validateConfig)(config);
|
|
74
274
|
const now = Date.now;
|
|
@@ -85,45 +285,132 @@ function createWotchi(config) {
|
|
|
85
285
|
});
|
|
86
286
|
const privacy = normalized.privacy;
|
|
87
287
|
let eventSequence = 0;
|
|
88
|
-
const
|
|
288
|
+
const matchingRule = (event) => normalized.rules.find((rule) => (rule.environment === undefined || rule.environment === event.environment) &&
|
|
289
|
+
(rule.route === undefined || rule.route === event.request?.route));
|
|
290
|
+
const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold, fingerprintOverride, severityOverride, trace, correlationId, operation, job, tags) => {
|
|
89
291
|
const timestamp = now();
|
|
90
292
|
const safeError = (0, redact_js_1.redactError)((0, normalize_js_1.normalizeError)(error, privacy), privacy);
|
|
91
293
|
const requestContext = safeRequest(request, privacy);
|
|
92
294
|
const eventContext = mergeContext(metadata, context, privacy);
|
|
295
|
+
const requestTrace = safeTrace(trace ?? requestContext?.trace, privacy);
|
|
296
|
+
const eventCorrelationId = safeShortString(correlationId ?? requestContext?.correlationId, privacy, 200);
|
|
297
|
+
const operationValue = safeShortString(operation ?? eventContext?.operation, privacy, 200);
|
|
298
|
+
const jobValue = safeShortString(job ?? eventContext?.job ?? eventContext?.queue, privacy, 200);
|
|
299
|
+
const eventTags = safeTags(tags, privacy);
|
|
93
300
|
const safeEvent = {
|
|
94
301
|
id: `event-${eventSequence + 1}`,
|
|
95
302
|
timestamp: new Date(timestamp).toISOString(),
|
|
96
303
|
service: normalized.service,
|
|
97
304
|
environment: normalized.environment,
|
|
305
|
+
...(normalized.instance === undefined ? {} : { instance: normalized.instance }),
|
|
98
306
|
...(normalized.release === undefined ? {} : { release: normalized.release }),
|
|
307
|
+
...(eventCorrelationId === undefined ? {} : { correlationId: eventCorrelationId }),
|
|
308
|
+
...(operationValue === undefined ? {} : { operation: operationValue.slice(0, 200) }),
|
|
309
|
+
...(jobValue === undefined ? {} : { job: jobValue.slice(0, 200) }),
|
|
310
|
+
...(eventTags === undefined ? {} : { tags: eventTags }),
|
|
99
311
|
error: safeError,
|
|
100
312
|
...(requestContext === undefined ? {} : { request: requestContext }),
|
|
313
|
+
...(requestTrace === undefined ? {} : { trace: requestTrace }),
|
|
101
314
|
...(eventContext === undefined ? {} : { context: eventContext }),
|
|
102
315
|
};
|
|
103
316
|
eventSequence += 1;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
317
|
+
const filteredEvent = deepFreeze(safeEvent);
|
|
318
|
+
if (normalized.filter !== undefined) {
|
|
319
|
+
try {
|
|
320
|
+
const accepted = normalized.filter(filteredEvent);
|
|
321
|
+
if (typeof accepted !== "boolean") {
|
|
322
|
+
diagnostics.filterFailures += 1;
|
|
323
|
+
diagnostics.eventsSuppressed += 1;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!accepted) {
|
|
327
|
+
diagnostics.eventsSuppressed += 1;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
diagnostics.filterFailures += 1;
|
|
333
|
+
diagnostics.eventsSuppressed += 1;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const rule = matchingRule(filteredEvent);
|
|
338
|
+
if (rule?.ignore === true) {
|
|
339
|
+
diagnostics.eventsSuppressed += 1;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
let fingerprint;
|
|
343
|
+
try {
|
|
344
|
+
fingerprint = normalizeFingerprintOverride(fingerprintOverride, filteredEvent);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
diagnostics.fingerprintCallbackFailures += 1;
|
|
348
|
+
}
|
|
349
|
+
if (fingerprint === undefined && normalized.fingerprint !== undefined) {
|
|
350
|
+
try {
|
|
351
|
+
fingerprint = normalizeFingerprintOverride(normalized.fingerprint, filteredEvent);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
diagnostics.fingerprintCallbackFailures += 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
fingerprint ??= (0, fingerprint_js_1.fingerprintSafeErrorEvent)(filteredEvent);
|
|
358
|
+
const group = groupStore.record(fingerprint, filteredEvent, rule?.severity ?? severityOverride ?? "medium");
|
|
359
|
+
const policyInput = {
|
|
107
360
|
group,
|
|
108
361
|
now: timestamp,
|
|
109
|
-
alertThreshold,
|
|
362
|
+
alertThreshold: rule?.alertThreshold ?? alertThreshold,
|
|
110
363
|
cooldownMs: normalized.grouping.cooldownMs,
|
|
111
364
|
eventKind,
|
|
112
|
-
}
|
|
365
|
+
};
|
|
366
|
+
const manualSeverity = rule?.severity ?? severityOverride;
|
|
367
|
+
const decision = manualSeverity === undefined
|
|
368
|
+
? (0, incident_policy_js_1.evaluateIncidentPolicy)(policyInput)
|
|
369
|
+
: (0, incident_policy_js_1.evaluateIncidentPolicy)({ ...policyInput, manualSeverity });
|
|
113
370
|
if (decision.shouldAlert) {
|
|
114
|
-
const
|
|
115
|
-
|
|
371
|
+
const builtAlert = (0, incident_builder_js_1.buildIncidentAlert)(group, decision);
|
|
372
|
+
const links = renderLinks(normalized.links, filteredEvent, fingerprint);
|
|
373
|
+
const alert = deepFreeze({
|
|
374
|
+
...builtAlert,
|
|
375
|
+
...(links === undefined ? {} : { links }),
|
|
376
|
+
});
|
|
377
|
+
let finalAlert = alert;
|
|
378
|
+
if (normalized.beforeSend !== undefined) {
|
|
379
|
+
let transformed;
|
|
380
|
+
try {
|
|
381
|
+
transformed = normalized.beforeSend(alert);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
diagnostics.beforeSendFailures += 1;
|
|
385
|
+
diagnostics.eventsSuppressed += 1;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (transformed === null) {
|
|
389
|
+
diagnostics.eventsSuppressed += 1;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (transformed !== undefined) {
|
|
393
|
+
const sanitized = safeAlertFromValue(transformed, alert, privacy);
|
|
394
|
+
if (sanitized === undefined) {
|
|
395
|
+
diagnostics.beforeSendFailures += 1;
|
|
396
|
+
diagnostics.eventsSuppressed += 1;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
finalAlert = sanitized;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (queue.enqueue(finalAlert, normalized.notifiers)) {
|
|
116
403
|
groupStore.markAlerted(fingerprint, timestamp);
|
|
117
404
|
}
|
|
118
405
|
}
|
|
119
406
|
diagnostics.capturedEvents += 1;
|
|
120
407
|
};
|
|
121
|
-
const captureException = (error, context) => {
|
|
408
|
+
const captureException = (error, context, options) => {
|
|
122
409
|
if (!normalized.enabled) {
|
|
123
410
|
return;
|
|
124
411
|
}
|
|
125
412
|
try {
|
|
126
|
-
captureSafeEvent(error, undefined, context,
|
|
413
|
+
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);
|
|
127
414
|
}
|
|
128
415
|
catch {
|
|
129
416
|
diagnostics.captureFailures += 1;
|
|
@@ -141,24 +428,86 @@ function createWotchi(config) {
|
|
|
141
428
|
throw new TypeError("event.message must be a string");
|
|
142
429
|
}
|
|
143
430
|
const alertThreshold = normalizeEventAlertThreshold(event.alertThreshold);
|
|
144
|
-
captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error", alertThreshold ?? normalized.grouping.alertThreshold);
|
|
431
|
+
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);
|
|
145
432
|
}
|
|
146
433
|
catch {
|
|
147
434
|
diagnostics.captureFailures += 1;
|
|
148
435
|
}
|
|
149
436
|
};
|
|
437
|
+
const readDiagnostics = () => (0, diagnostics_js_1.snapshotDiagnostics)(diagnostics, {
|
|
438
|
+
groupsEvicted: groupStore.groupsEvicted(),
|
|
439
|
+
alertsQueued: queue.alertsQueued(),
|
|
440
|
+
alertsDropped: queue.alertsDropped(),
|
|
441
|
+
alertsSent: queue.alertsSent(),
|
|
442
|
+
notifierFailures: queue.notifierFailures(),
|
|
443
|
+
activeGroups: groupStore.size(),
|
|
444
|
+
pendingAlerts: queue.pending(),
|
|
445
|
+
});
|
|
446
|
+
const testAlert = async () => {
|
|
447
|
+
const timestamp = new Date(now()).toISOString();
|
|
448
|
+
const alert = {
|
|
449
|
+
id: `wotchi-test-${timestamp}`,
|
|
450
|
+
fingerprint: "wotchi-test-alert",
|
|
451
|
+
title: "Wotchi — Test alert",
|
|
452
|
+
severity: "low",
|
|
453
|
+
summary: "This is a notifier configuration test alert.",
|
|
454
|
+
suggestedActions: ["Confirm this destination received the test alert."],
|
|
455
|
+
firstSeenAt: timestamp,
|
|
456
|
+
lastSeenAt: timestamp,
|
|
457
|
+
occurrences: 1,
|
|
458
|
+
service: normalized.service,
|
|
459
|
+
environment: normalized.environment,
|
|
460
|
+
...(normalized.instance === undefined ? {} : { instance: normalized.instance }),
|
|
461
|
+
...(normalized.release === undefined ? {} : { release: normalized.release }),
|
|
462
|
+
};
|
|
463
|
+
let jobResult;
|
|
464
|
+
if (!queue.enqueue(alert, normalized.notifiers, (result) => {
|
|
465
|
+
jobResult = result;
|
|
466
|
+
})) {
|
|
467
|
+
return {
|
|
468
|
+
status: "queue-full",
|
|
469
|
+
configurationAccepted: true,
|
|
470
|
+
queued: false,
|
|
471
|
+
flushed: false,
|
|
472
|
+
delivered: false,
|
|
473
|
+
notifierFailures: 0,
|
|
474
|
+
diagnostics: readDiagnostics(),
|
|
475
|
+
error: "Notification queue is full; test alert was not queued.",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
await queue.flush();
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return {
|
|
483
|
+
status: "timeout",
|
|
484
|
+
configurationAccepted: true,
|
|
485
|
+
queued: true,
|
|
486
|
+
flushed: false,
|
|
487
|
+
delivered: false,
|
|
488
|
+
notifierFailures: jobResult?.notifierFailures ?? 0,
|
|
489
|
+
diagnostics: readDiagnostics(),
|
|
490
|
+
error: "Notification queue flush timed out.",
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
const notifierFailures = jobResult?.notifierFailures ?? 0;
|
|
494
|
+
const status = notifierFailures > 0 ? "notifier-failed" : "sent";
|
|
495
|
+
return {
|
|
496
|
+
status,
|
|
497
|
+
configurationAccepted: true,
|
|
498
|
+
queued: true,
|
|
499
|
+
flushed: true,
|
|
500
|
+
delivered: status === "sent",
|
|
501
|
+
notifierFailures,
|
|
502
|
+
diagnostics: readDiagnostics(),
|
|
503
|
+
...(status === "notifier-failed" ? { error: "One or more notifiers failed." } : {}),
|
|
504
|
+
};
|
|
505
|
+
};
|
|
150
506
|
return {
|
|
151
507
|
captureException,
|
|
152
508
|
captureEvent,
|
|
509
|
+
testAlert,
|
|
153
510
|
flush: (timeoutMs) => queue.flush(timeoutMs),
|
|
154
|
-
getDiagnostics:
|
|
155
|
-
groupsEvicted: groupStore.groupsEvicted(),
|
|
156
|
-
alertsQueued: queue.alertsQueued(),
|
|
157
|
-
alertsDropped: queue.alertsDropped(),
|
|
158
|
-
alertsSent: queue.alertsSent(),
|
|
159
|
-
notifierFailures: queue.notifierFailures(),
|
|
160
|
-
activeGroups: groupStore.size(),
|
|
161
|
-
pendingAlerts: queue.pending(),
|
|
162
|
-
}),
|
|
511
|
+
getDiagnostics: readDiagnostics,
|
|
163
512
|
};
|
|
164
513
|
}
|