@futurewindai/wotchi 0.1.0-beta.1 → 0.1.0-beta.2
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 +110 -61
- package/SECURITY.md +2 -2
- package/dist/cjs/core/client.js +13 -3
- package/dist/cjs/core/redact.js +31 -2
- package/dist/cjs/integrations/express/error-handler.js +2 -0
- package/dist/cjs/integrations/express/index.js +3 -1
- package/dist/cjs/integrations/express/state.js +30 -0
- package/dist/cjs/integrations/express/status-observer.js +80 -0
- package/dist/cjs/integrations/nest/index.js +2 -1
- package/dist/cjs/integrations/nest/register.js +9 -0
- package/dist/cjs/notifiers/console.js +32 -12
- package/dist/esm/core/client.js +13 -3
- package/dist/esm/core/redact.js +31 -2
- package/dist/esm/integrations/express/error-handler.js +2 -0
- package/dist/esm/integrations/express/index.js +1 -0
- package/dist/esm/integrations/express/state.js +26 -0
- package/dist/esm/integrations/express/status-observer.js +77 -0
- package/dist/esm/integrations/nest/index.js +1 -1
- package/dist/esm/integrations/nest/register.js +8 -0
- package/dist/esm/notifiers/console.js +32 -12
- package/dist/types/core/types.d.ts +2 -0
- package/dist/types/integrations/express/index.d.ts +2 -0
- package/dist/types/integrations/express/state.d.ts +2 -0
- package/dist/types/integrations/express/status-observer.d.ts +11 -0
- package/dist/types/integrations/nest/index.d.ts +2 -1
- package/dist/types/integrations/nest/register.d.ts +3 -0
- package/dist/types/notifiers/console.d.ts +1 -1
- package/dist/types-cjs/core/client.d.cts +2 -0
- package/dist/types-cjs/core/config.d.cts +29 -0
- package/dist/types-cjs/core/diagnostics.d.cts +7 -0
- package/dist/types-cjs/core/errors.d.cts +3 -0
- package/dist/types-cjs/core/fingerprint.d.cts +3 -0
- package/dist/types-cjs/core/group-store.d.cts +15 -0
- package/dist/types-cjs/core/incident-builder.d.cts +3 -0
- package/dist/types-cjs/core/incident-policy.d.cts +15 -0
- package/dist/types-cjs/core/normalize.d.cts +17 -0
- package/dist/types-cjs/core/notification-queue.d.cts +16 -0
- package/dist/types-cjs/core/process-monitor.d.cts +5 -0
- package/dist/types-cjs/core/redact.d.cts +11 -0
- package/dist/types-cjs/core/rolling-window.d.cts +12 -0
- package/dist/types-cjs/core/stack-frame.d.cts +2 -0
- package/dist/types-cjs/core/types.d.cts +111 -0
- package/dist/types-cjs/index.d.cts +9 -0
- package/dist/types-cjs/integrations/express/error-handler.d.cts +4 -0
- package/dist/types-cjs/integrations/express/index.d.cts +9 -0
- package/dist/types-cjs/integrations/express/request-context.d.cts +5 -0
- package/dist/types-cjs/integrations/express/state.d.cts +2 -0
- package/dist/types-cjs/integrations/express/status-observer.d.cts +11 -0
- package/dist/types-cjs/integrations/nest/exception-filter.d.cts +10 -0
- package/dist/types-cjs/integrations/nest/index.d.cts +5 -0
- package/dist/types-cjs/integrations/nest/register.d.cts +13 -0
- package/dist/types-cjs/integrations/nest/request-context.d.cts +5 -0
- package/dist/types-cjs/integrations/request-context.d.cts +13 -0
- package/dist/types-cjs/notifiers/console.d.cts +3 -0
- package/dist/types-cjs/notifiers/telegram-format.d.cts +2 -0
- package/dist/types-cjs/notifiers/telegram-http.d.cts +21 -0
- package/dist/types-cjs/notifiers/telegram.d.cts +4 -0
- package/package.json +26 -6
package/README.md
CHANGED
|
@@ -1,26 +1,86 @@
|
|
|
1
1
|
# Wotchi
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Bounded incident alerts for Node.js services.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Status:** Public beta (`0.1.0-beta.2`). 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
|
+
[](https://github.com/FutureWindAI/Wotchi/actions/workflows/codeql.yml)
|
|
9
|
+
[](https://www.npmjs.com/package/@futurewindai/wotchi)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](https://nodejs.org/en/about/previous-releases)
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
Wotchi captures application errors in-process, removes sensitive values, groups repeated failures, and delivers bounded console or Telegram alerts without changing framework response handling.
|
|
10
14
|
|
|
11
|
-
##
|
|
15
|
+
## Quick start
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- sensitive-data redaction before storage, fingerprinting, logging, or transmission;
|
|
17
|
-
- stable fingerprints and deterministic incident summaries;
|
|
18
|
-
- bounded in-memory grouping, thresholds, cooldowns, and notification queueing;
|
|
19
|
-
- console and Telegram notifiers;
|
|
20
|
-
- CommonJS and ECMAScript module package exports;
|
|
21
|
-
- measured latency, CPU, heap, queue, and package-size release gates.
|
|
17
|
+
```bash
|
|
18
|
+
npm install @futurewindai/wotchi@beta
|
|
19
|
+
```
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
```ts
|
|
22
|
+
import { consoleNotifier, createWotchi } from "@futurewindai/wotchi";
|
|
23
|
+
|
|
24
|
+
const wotchi = createWotchi({
|
|
25
|
+
service: "orders-api",
|
|
26
|
+
environment: "development",
|
|
27
|
+
grouping: { alertThreshold: 1 },
|
|
28
|
+
notifiers: [consoleNotifier()],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
wotchi.captureException(new Error("database query failed"));
|
|
32
|
+
await wotchi.flush();
|
|
33
|
+
```
|
|
34
|
+
|
|
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
|
+
|
|
37
|
+
Example console output:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Wotchi — Medium incident
|
|
41
|
+
Service: orders-api
|
|
42
|
+
Environment: development
|
|
43
|
+
Summary: Observed 1 occurrences of Error: database query failed.
|
|
44
|
+
Occurrences: 1
|
|
45
|
+
First seen: 2026-08-08T12:00:00.000Z
|
|
46
|
+
Last seen: 2026-08-08T12:00:00.000Z
|
|
47
|
+
Suggested checks:
|
|
48
|
+
- Check database availability, connection saturation, and recent schema changes.
|
|
49
|
+
- Check the affected query and its dependency health before increasing capacity.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Timestamps and the fingerprint vary for each run. The alert is sanitized before it reaches a
|
|
53
|
+
notifier.
|
|
54
|
+
|
|
55
|
+
## What you get
|
|
56
|
+
|
|
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
|
+
| Optional status observation and JSON logs | Observe direct `401`/`403`/`429`/`5xx` responses and emit collector-friendly JSON. |
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
```mermaid
|
|
70
|
+
flowchart LR
|
|
71
|
+
A[Application error] --> B[Normalize and redact]
|
|
72
|
+
B --> C[Stable fingerprint]
|
|
73
|
+
C --> D[Threshold and cooldown]
|
|
74
|
+
D --> E[Bounded queue]
|
|
75
|
+
E --> F[Console]
|
|
76
|
+
E --> G[Telegram]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The same bounded capture path can be called from HTTP handlers, background workers, and queue
|
|
80
|
+
processors. Wotchi does not replace the host application's response, retry, or acknowledgement
|
|
81
|
+
logic.
|
|
82
|
+
|
|
83
|
+
## Framework integrations
|
|
24
84
|
|
|
25
85
|
Express applications install the middleware after routes and before the existing final error handler:
|
|
26
86
|
|
|
@@ -47,7 +107,11 @@ import { registerWotchiNest } from "@futurewindai/wotchi/nest";
|
|
|
47
107
|
registerWotchiNest(app, wotchi);
|
|
48
108
|
```
|
|
49
109
|
|
|
50
|
-
|
|
110
|
+
The package supports Node.js `>=18.18.0`. Express and NestJS adapters are optional subpath integrations, so applications only load the framework adapter they use.
|
|
111
|
+
|
|
112
|
+
## Telegram alerts
|
|
113
|
+
|
|
114
|
+
Telegram is an optional self-hosted notifier. Create a bot with BotFather, start or add it to the destination chat, and keep both values outside source control:
|
|
51
115
|
|
|
52
116
|
```ts
|
|
53
117
|
import { consoleNotifier, createWotchi, telegramNotifier } from "@futurewindai/wotchi";
|
|
@@ -65,9 +129,11 @@ const wotchi = createWotchi({
|
|
|
65
129
|
});
|
|
66
130
|
```
|
|
67
131
|
|
|
68
|
-
Wotchi does not ship a shared bot token.
|
|
132
|
+
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
|
+
|
|
134
|
+
## Process monitoring
|
|
69
135
|
|
|
70
|
-
Crash observation is
|
|
136
|
+
Crash observation is opt-in:
|
|
71
137
|
|
|
72
138
|
```ts
|
|
73
139
|
import { registerWotchiProcessMonitor } from "@futurewindai/wotchi";
|
|
@@ -76,53 +142,36 @@ const monitor = registerWotchiProcessMonitor(wotchi);
|
|
|
76
142
|
// monitor.unregister() when the host intentionally stops observing crashes
|
|
77
143
|
```
|
|
78
144
|
|
|
79
|
-
|
|
145
|
+
## What it is not
|
|
80
146
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
notifiers: [consoleNotifier()],
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
wotchi.captureException(new Error("database query failed"));
|
|
91
|
-
await wotchi.flush();
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
The default policy emits after three matching errors in one minute and suppresses duplicate alerts during the cooldown. The capture path remains synchronous; `flush()` is only needed when the host explicitly wants to wait for notifier work.
|
|
95
|
-
|
|
96
|
-
## Product Principles
|
|
97
|
-
|
|
98
|
-
- **Host safety:** Wotchi must not break, suppress, or materially delay the application's normal error flow.
|
|
99
|
-
- **Bounded overhead:** memory, payloads, groups, and queued notifications must all have explicit limits.
|
|
100
|
-
- **Privacy first:** request bodies, response bodies, raw headers, and arbitrary environment variables are outside the first release.
|
|
101
|
-
- **Deterministic first:** the MVP must work without an AI provider, database, hosted account, or collector.
|
|
102
|
-
- **Honest compatibility:** the MVP targets Node.js 18.18 and later. Supported framework/module combinations are claimed only after packed-tarball CI verification. Maintained Node.js LTS versions are recommended for production.
|
|
147
|
+
- A full observability, APM, or log-management platform.
|
|
148
|
+
- A hosted dashboard, collector, or persistent incident database.
|
|
149
|
+
- An AI-generated incident-summary service in this release.
|
|
150
|
+
- A Slack, Discord, email, or generic webhook notifier in this release.
|
|
151
|
+
- A Docker, Kubernetes, or Helm collector bundled into the npm SDK.
|
|
152
|
+
- An automatic-remediation system.
|
|
103
153
|
|
|
104
|
-
##
|
|
154
|
+
## Documentation
|
|
105
155
|
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
- Slack, Discord, email, or generic webhook notifiers;
|
|
110
|
-
- logger transports, Docker collectors, Kubernetes agents, or Helm charts in the npm SDK itself; a separate future collector/relay deployment may receive its own Docker and Helm milestone;
|
|
111
|
-
- automatic remediation.
|
|
112
|
-
|
|
113
|
-
A token-based hosted demo viewer is a separate possible future milestone, not part of the package MVP.
|
|
114
|
-
|
|
115
|
-
## Project Documents
|
|
116
|
-
|
|
117
|
-
- [Architecture and package boundaries](docs/ARCHITECTURE.md)
|
|
118
|
-
- [Setup](docs/SETUP.md)
|
|
156
|
+
- [Getting started](docs/GETTING_STARTED.md)
|
|
157
|
+
- [Examples](docs/EXAMPLES.md)
|
|
158
|
+
- [API reference](docs/API.md)
|
|
119
159
|
- [Configuration](docs/CONFIGURATION.md)
|
|
120
|
-
- [
|
|
121
|
-
- [
|
|
122
|
-
- [
|
|
123
|
-
- [
|
|
124
|
-
- [
|
|
160
|
+
- [Compatibility](docs/COMPATIBILITY.md)
|
|
161
|
+
- [Performance](docs/PERFORMANCE.md)
|
|
162
|
+
- [Architecture](docs/ARCHITECTURE.md)
|
|
163
|
+
- [Roadmap](docs/ROADMAP.md)
|
|
164
|
+
- [Troubleshooting](docs/TROUBLESHOOTING.md)
|
|
165
|
+
- [FAQ](docs/FAQ.md)
|
|
166
|
+
- [Security and privacy](docs/SECURITY.md)
|
|
167
|
+
- [Threat model](docs/THREAT_MODEL.md)
|
|
168
|
+
- [Contributing](CONTRIBUTING.md)
|
|
125
169
|
- [Changelog](CHANGELOG.md)
|
|
170
|
+
- [GitHub releases](https://github.com/FutureWindAI/Wotchi/releases)
|
|
126
171
|
- [Apache License 2.0](LICENSE)
|
|
127
172
|
|
|
128
|
-
|
|
173
|
+
## Security
|
|
174
|
+
|
|
175
|
+
Do not include real secrets or customer error data in issues, examples, or test fixtures. Report security vulnerabilities privately using [SECURITY.md](SECURITY.md).
|
|
176
|
+
|
|
177
|
+
Wotchi is open source and maintained by FutureWind AI.
|
package/SECURITY.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported Versions
|
|
4
4
|
|
|
5
|
-
Wotchi
|
|
5
|
+
Wotchi targets applications running Node.js `>=18.18.0` and is built and tested across the Node.js 18–26 matrix. Node.js versions below 18.18 are not supported.
|
|
6
6
|
|
|
7
|
-
The current
|
|
7
|
+
The current package contains the framework-independent capture core, console and Telegram notifiers, Express 4/5 middleware, NestJS 10/11 exception-filter integration, and opt-in process monitoring. Supported runtime and framework combinations are documented after packed-tarball compatibility and release-security checks.
|
|
8
8
|
|
|
9
9
|
## Reporting a Vulnerability
|
|
10
10
|
|
package/dist/cjs/core/client.js
CHANGED
|
@@ -60,6 +60,15 @@ const safeMessage = (value, privacy) => {
|
|
|
60
60
|
const normalized = (0, normalize_js_1.normalizeUnknown)(value, privacy);
|
|
61
61
|
return typeof normalized === "string" ? normalized : "[unreadable value]";
|
|
62
62
|
};
|
|
63
|
+
const normalizeEventAlertThreshold = (value) => {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 1_000_000) {
|
|
68
|
+
throw new TypeError("event.alertThreshold must be a positive integer no greater than 1000000");
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
};
|
|
63
72
|
function createWotchi(config) {
|
|
64
73
|
const normalized = (0, config_js_1.validateConfig)(config);
|
|
65
74
|
const now = Date.now;
|
|
@@ -76,7 +85,7 @@ function createWotchi(config) {
|
|
|
76
85
|
});
|
|
77
86
|
const privacy = normalized.privacy;
|
|
78
87
|
let eventSequence = 0;
|
|
79
|
-
const captureSafeEvent = (error, metadata, context, request, eventKind = "error") => {
|
|
88
|
+
const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold) => {
|
|
80
89
|
const timestamp = now();
|
|
81
90
|
const safeError = (0, redact_js_1.redactError)((0, normalize_js_1.normalizeError)(error, privacy), privacy);
|
|
82
91
|
const requestContext = safeRequest(request, privacy);
|
|
@@ -97,7 +106,7 @@ function createWotchi(config) {
|
|
|
97
106
|
const decision = (0, incident_policy_js_1.evaluateIncidentPolicy)({
|
|
98
107
|
group,
|
|
99
108
|
now: timestamp,
|
|
100
|
-
alertThreshold
|
|
109
|
+
alertThreshold,
|
|
101
110
|
cooldownMs: normalized.grouping.cooldownMs,
|
|
102
111
|
eventKind,
|
|
103
112
|
});
|
|
@@ -131,7 +140,8 @@ function createWotchi(config) {
|
|
|
131
140
|
if (typeof event.message !== "string") {
|
|
132
141
|
throw new TypeError("event.message must be a string");
|
|
133
142
|
}
|
|
134
|
-
|
|
143
|
+
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);
|
|
135
145
|
}
|
|
136
146
|
catch {
|
|
137
147
|
diagnostics.captureFailures += 1;
|
package/dist/cjs/core/redact.js
CHANGED
|
@@ -51,11 +51,40 @@ const redactCardLikeValues = (value) => value.replace(/\b\d[\d -]{11,25}\d\b/g,
|
|
|
51
51
|
const digits = candidate.replace(/[ -]/g, "");
|
|
52
52
|
return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? exports.REDACTED : candidate;
|
|
53
53
|
});
|
|
54
|
+
const CONNECTION_URL_PATTERN = /\b(?:postgres(?:ql)?|rediss?|mongodb(?:\+srv)?):\/\/[^\s"'<>]+/gi;
|
|
55
|
+
const CONNECTION_QUERY_SECRET_PATTERN = /([?&](?:password|passwd|pass|secret|token|api[-_]?key|access[-_]?token|refresh[-_]?token|sslpassword)=)[^&#\s]*/gi;
|
|
56
|
+
const URL_TRAILING_PUNCTUATION = /[),.;!?]+$/;
|
|
57
|
+
const redactConnectionUrl = (candidate) => {
|
|
58
|
+
let core = candidate;
|
|
59
|
+
let suffix = "";
|
|
60
|
+
while (URL_TRAILING_PUNCTUATION.test(core)) {
|
|
61
|
+
suffix = `${core.slice(-1)}${suffix}`;
|
|
62
|
+
core = core.slice(0, -1);
|
|
63
|
+
}
|
|
64
|
+
const schemeEnd = core.indexOf("://");
|
|
65
|
+
if (schemeEnd < 0) {
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
const authorityStart = schemeEnd + 3;
|
|
69
|
+
const authorityOffset = core.slice(authorityStart).search(/[/?#]/);
|
|
70
|
+
const authorityEnd = authorityOffset < 0 ? core.length : authorityStart + authorityOffset;
|
|
71
|
+
const authority = core.slice(authorityStart, authorityEnd);
|
|
72
|
+
const atIndex = authority.lastIndexOf("@");
|
|
73
|
+
const redactedAuthority = atIndex < 0 ? authority : `${exports.REDACTED}@${authority.slice(atIndex + 1)}`;
|
|
74
|
+
const remainder = core
|
|
75
|
+
.slice(authorityEnd)
|
|
76
|
+
.replace(CONNECTION_QUERY_SECRET_PATTERN, `$1${exports.REDACTED}`);
|
|
77
|
+
return `${core.slice(0, authorityStart)}${redactedAuthority}${remainder}${suffix}`;
|
|
78
|
+
};
|
|
79
|
+
const redactConnectionUrls = (value) => value.replace(CONNECTION_URL_PATTERN, redactConnectionUrl);
|
|
54
80
|
const redactString = (value, maxStringLength) => {
|
|
55
|
-
let result = value;
|
|
81
|
+
let result = redactConnectionUrls(value);
|
|
56
82
|
result = result.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, exports.REDACTED);
|
|
57
83
|
result = result.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${exports.REDACTED}`);
|
|
58
|
-
result = result.replace(/\b(?:authorization|password|passwd|secret|token|api[-_]?key)\s*[:=]\s*[^\s,;]+/gi, (match) =>
|
|
84
|
+
result = result.replace(/\b(?:authorization|password|passwd|secret|token|api[-_]?key)\s*[:=]\s*[^\s,;]+/gi, (match) => {
|
|
85
|
+
const separatorIndex = match.search(/[:=]/);
|
|
86
|
+
return separatorIndex < 0 ? exports.REDACTED : `${match.slice(0, separatorIndex + 1)}${exports.REDACTED}`;
|
|
87
|
+
});
|
|
59
88
|
result = result.replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, exports.REDACTED);
|
|
60
89
|
result = result.replace(/\b(?:github_pat|ghp|xox[baprs]-|sk|pk|AKIA)[A-Za-z0-9_-]{8,}\b/gi, exports.REDACTED);
|
|
61
90
|
result = redactCardLikeValues(result);
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createExpressErrorHandler = createExpressErrorHandler;
|
|
4
4
|
const request_context_js_1 = require("./request-context.js");
|
|
5
|
+
const state_js_1 = require("./state.js");
|
|
5
6
|
function createExpressErrorHandler(client, options) {
|
|
6
7
|
return (error, request, response, next) => {
|
|
8
|
+
(0, state_js_1.markExpressErrorCaptured)(request);
|
|
7
9
|
try {
|
|
8
10
|
client.captureException(error, (0, request_context_js_1.getExpressRequestContext)(request, response, options));
|
|
9
11
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.telegramNotifier = exports.createWotchi = exports.consoleNotifier = void 0;
|
|
3
|
+
exports.wotchiStatusObserver = exports.telegramNotifier = exports.createWotchi = exports.consoleNotifier = void 0;
|
|
4
4
|
exports.wotchiErrorHandler = wotchiErrorHandler;
|
|
5
5
|
const error_handler_js_1 = require("./error-handler.js");
|
|
6
6
|
var index_js_1 = require("../../index.js");
|
|
7
7
|
Object.defineProperty(exports, "consoleNotifier", { enumerable: true, get: function () { return index_js_1.consoleNotifier; } });
|
|
8
8
|
Object.defineProperty(exports, "createWotchi", { enumerable: true, get: function () { return index_js_1.createWotchi; } });
|
|
9
9
|
Object.defineProperty(exports, "telegramNotifier", { enumerable: true, get: function () { return index_js_1.telegramNotifier; } });
|
|
10
|
+
var status_observer_js_1 = require("./status-observer.js");
|
|
11
|
+
Object.defineProperty(exports, "wotchiStatusObserver", { enumerable: true, get: function () { return status_observer_js_1.wotchiStatusObserver; } });
|
|
10
12
|
function wotchiErrorHandler(client, options) {
|
|
11
13
|
return (0, error_handler_js_1.createExpressErrorHandler)(client, options);
|
|
12
14
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.markExpressErrorCaptured = markExpressErrorCaptured;
|
|
4
|
+
exports.wasExpressErrorCaptured = wasExpressErrorCaptured;
|
|
5
|
+
const WOTCHI_ERROR_CAPTURED = Symbol("wotchi.errorCaptured");
|
|
6
|
+
const asMarkedRequest = (request) => typeof request === "object" && request !== null ? request : undefined;
|
|
7
|
+
function markExpressErrorCaptured(request) {
|
|
8
|
+
const markedRequest = asMarkedRequest(request);
|
|
9
|
+
if (markedRequest === undefined) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
markedRequest[WOTCHI_ERROR_CAPTURED] = true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// A request object can be frozen by host middleware; the observer remains best-effort.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function wasExpressErrorCaptured(request) {
|
|
20
|
+
const markedRequest = asMarkedRequest(request);
|
|
21
|
+
if (markedRequest === undefined) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return markedRequest[WOTCHI_ERROR_CAPTURED] === true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.wotchiStatusObserver = wotchiStatusObserver;
|
|
4
|
+
const request_context_js_1 = require("./request-context.js");
|
|
5
|
+
const state_js_1 = require("./state.js");
|
|
6
|
+
const DEFAULT_STATUS_CODES = [401, 403, 429];
|
|
7
|
+
const DEFAULT_STATUS_CLASSES = ["5xx"];
|
|
8
|
+
const MAX_STATUS_CODES = 100;
|
|
9
|
+
const normalizeStatuses = (values, defaults) => {
|
|
10
|
+
const selected = values ?? defaults;
|
|
11
|
+
if (selected.length > MAX_STATUS_CODES) {
|
|
12
|
+
throw new TypeError("statusCodes must contain at most 100 values");
|
|
13
|
+
}
|
|
14
|
+
for (const status of selected) {
|
|
15
|
+
if (!Number.isSafeInteger(status) || status < 100 || status > 599) {
|
|
16
|
+
throw new TypeError("statusCodes must contain HTTP status codes from 100 through 599");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return new Set(selected);
|
|
20
|
+
};
|
|
21
|
+
const normalizeStatusClasses = (values) => {
|
|
22
|
+
const selected = values ?? DEFAULT_STATUS_CLASSES;
|
|
23
|
+
for (const statusClass of selected) {
|
|
24
|
+
if (statusClass !== "4xx" && statusClass !== "5xx") {
|
|
25
|
+
throw new TypeError('statusClasses must contain only "4xx" or "5xx"');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return new Set(selected);
|
|
29
|
+
};
|
|
30
|
+
const normalizeAlertThreshold = (value) => {
|
|
31
|
+
if (value === undefined) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 1_000_000) {
|
|
35
|
+
throw new TypeError("alertThreshold must be a positive integer no greater than 1000000");
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
const normalizeOptions = (options) => {
|
|
40
|
+
const alertThreshold = normalizeAlertThreshold(options?.alertThreshold);
|
|
41
|
+
return {
|
|
42
|
+
...(options?.requestIdProperty === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { requestIdProperty: options.requestIdProperty }),
|
|
45
|
+
statusCodes: normalizeStatuses(options?.statusCodes, DEFAULT_STATUS_CODES),
|
|
46
|
+
statusClasses: normalizeStatusClasses(options?.statusClasses),
|
|
47
|
+
ignoreStatusCodes: normalizeStatuses(options?.ignoreStatusCodes, []),
|
|
48
|
+
...(alertThreshold === undefined ? {} : { alertThreshold }),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const isObservedStatus = (statusCode, options) => !options.ignoreStatusCodes.has(statusCode) &&
|
|
52
|
+
(options.statusCodes.has(statusCode) ||
|
|
53
|
+
(statusCode >= 400 && statusCode < 500 && options.statusClasses.has("4xx")) ||
|
|
54
|
+
(statusCode >= 500 && statusCode < 600 && options.statusClasses.has("5xx")));
|
|
55
|
+
function wotchiStatusObserver(client, options) {
|
|
56
|
+
const normalizedOptions = normalizeOptions(options);
|
|
57
|
+
return (request, response, next) => {
|
|
58
|
+
response.once("finish", () => {
|
|
59
|
+
const statusCode = response.statusCode;
|
|
60
|
+
if ((0, state_js_1.wasExpressErrorCaptured)(request) || !isObservedStatus(statusCode, normalizedOptions)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const requestContext = (0, request_context_js_1.getExpressRequestContext)(request, response, normalizedOptions);
|
|
65
|
+
client.captureEvent({
|
|
66
|
+
level: "error",
|
|
67
|
+
message: `HTTP ${statusCode} response`,
|
|
68
|
+
...(normalizedOptions.alertThreshold === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { alertThreshold: normalizedOptions.alertThreshold }),
|
|
71
|
+
...(requestContext === undefined ? {} : { request: requestContext }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Status observation must never change the host response lifecycle.
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
next();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.registerWotchiNest = exports.telegramNotifier = exports.createWotchi = exports.consoleNotifier = void 0;
|
|
3
|
+
exports.registerWotchiNestStatusObserver = exports.registerWotchiNest = exports.telegramNotifier = exports.createWotchi = exports.consoleNotifier = void 0;
|
|
4
4
|
var index_js_1 = require("../../index.js");
|
|
5
5
|
Object.defineProperty(exports, "consoleNotifier", { enumerable: true, get: function () { return index_js_1.consoleNotifier; } });
|
|
6
6
|
Object.defineProperty(exports, "createWotchi", { enumerable: true, get: function () { return index_js_1.createWotchi; } });
|
|
7
7
|
Object.defineProperty(exports, "telegramNotifier", { enumerable: true, get: function () { return index_js_1.telegramNotifier; } });
|
|
8
8
|
var register_js_1 = require("./register.js");
|
|
9
9
|
Object.defineProperty(exports, "registerWotchiNest", { enumerable: true, get: function () { return register_js_1.registerWotchiNest; } });
|
|
10
|
+
Object.defineProperty(exports, "registerWotchiNestStatusObserver", { enumerable: true, get: function () { return register_js_1.registerWotchiNestStatusObserver; } });
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerWotchiNest = registerWotchiNest;
|
|
4
|
+
exports.registerWotchiNestStatusObserver = registerWotchiNestStatusObserver;
|
|
4
5
|
const core_1 = require("@nestjs/core");
|
|
5
6
|
const exception_filter_js_1 = require("./exception-filter.js");
|
|
7
|
+
const status_observer_js_1 = require("../express/status-observer.js");
|
|
6
8
|
function registerWotchiNest(app, client, options) {
|
|
7
9
|
const application = app;
|
|
8
10
|
const directAdapter = application.getHttpAdapter?.();
|
|
@@ -11,3 +13,10 @@ function registerWotchiNest(app, client, options) {
|
|
|
11
13
|
: directAdapter;
|
|
12
14
|
application.useGlobalFilters(new exception_filter_js_1.WotchiNestExceptionFilter(client, httpAdapter, options));
|
|
13
15
|
}
|
|
16
|
+
function registerWotchiNestStatusObserver(app, client, options) {
|
|
17
|
+
const application = app;
|
|
18
|
+
if (typeof application.use !== "function") {
|
|
19
|
+
throw new TypeError("Nest application does not expose use(); an Express-based adapter is required");
|
|
20
|
+
}
|
|
21
|
+
application.use((0, status_observer_js_1.wotchiStatusObserver)(client, options));
|
|
22
|
+
}
|
|
@@ -2,22 +2,42 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatConsoleAlert = formatConsoleAlert;
|
|
4
4
|
exports.consoleNotifier = consoleNotifier;
|
|
5
|
+
const redact_js_1 = require("../core/redact.js");
|
|
5
6
|
const MAX_FIELD_LENGTH = 1_000;
|
|
6
7
|
const MAX_ACTIONS = 5;
|
|
7
8
|
const limit = (value, maxLength = MAX_FIELD_LENGTH) => value.slice(0, maxLength);
|
|
8
|
-
|
|
9
|
-
const
|
|
9
|
+
const safeText = (value, maxLength = MAX_FIELD_LENGTH) => {
|
|
10
|
+
const redacted = (0, redact_js_1.redactValue)(value, { maxStringLength: maxLength });
|
|
11
|
+
return typeof redacted === "string" ? limit(redacted, maxLength) : "[unreadable value]";
|
|
12
|
+
};
|
|
13
|
+
const boundedAlert = (alert) => ({
|
|
14
|
+
title: safeText(alert.title),
|
|
15
|
+
fingerprint: safeText(alert.fingerprint),
|
|
16
|
+
severity: alert.severity,
|
|
17
|
+
summary: safeText(alert.summary),
|
|
18
|
+
suggestedActions: alert.suggestedActions
|
|
10
19
|
.slice(0, MAX_ACTIONS)
|
|
11
|
-
.map((action) =>
|
|
12
|
-
|
|
20
|
+
.map((action) => safeText(action, 300)),
|
|
21
|
+
firstSeenAt: safeText(alert.firstSeenAt, 100),
|
|
22
|
+
lastSeenAt: safeText(alert.lastSeenAt, 100),
|
|
23
|
+
occurrences: Number.isSafeInteger(alert.occurrences) && alert.occurrences >= 0 ? alert.occurrences : 0,
|
|
24
|
+
service: safeText(alert.service, 300),
|
|
25
|
+
environment: safeText(alert.environment, 300),
|
|
26
|
+
});
|
|
27
|
+
function formatConsoleAlert(alert, format = "text") {
|
|
28
|
+
const bounded = boundedAlert(alert);
|
|
29
|
+
if (format === "json") {
|
|
30
|
+
return JSON.stringify(bounded);
|
|
31
|
+
}
|
|
32
|
+
const actions = bounded.suggestedActions.map((action) => `- ${action}`).join("\n");
|
|
13
33
|
return [
|
|
14
|
-
|
|
15
|
-
`Service: ${
|
|
16
|
-
`Environment: ${
|
|
17
|
-
`Summary: ${
|
|
18
|
-
`Occurrences: ${
|
|
19
|
-
`First seen: ${
|
|
20
|
-
`Last seen: ${
|
|
34
|
+
bounded.title,
|
|
35
|
+
`Service: ${bounded.service}`,
|
|
36
|
+
`Environment: ${bounded.environment}`,
|
|
37
|
+
`Summary: ${bounded.summary}`,
|
|
38
|
+
`Occurrences: ${bounded.occurrences}`,
|
|
39
|
+
`First seen: ${bounded.firstSeenAt}`,
|
|
40
|
+
`Last seen: ${bounded.lastSeenAt}`,
|
|
21
41
|
"Suggested checks:",
|
|
22
42
|
actions,
|
|
23
43
|
].join("\n");
|
|
@@ -27,7 +47,7 @@ function consoleNotifier(options) {
|
|
|
27
47
|
return {
|
|
28
48
|
name: "console",
|
|
29
49
|
async send(alert) {
|
|
30
|
-
write(formatConsoleAlert(alert));
|
|
50
|
+
write(formatConsoleAlert(alert, options?.format ?? "text"));
|
|
31
51
|
},
|
|
32
52
|
};
|
|
33
53
|
}
|
package/dist/esm/core/client.js
CHANGED
|
@@ -57,6 +57,15 @@ const safeMessage = (value, privacy) => {
|
|
|
57
57
|
const normalized = normalizeUnknown(value, privacy);
|
|
58
58
|
return typeof normalized === "string" ? normalized : "[unreadable value]";
|
|
59
59
|
};
|
|
60
|
+
const normalizeEventAlertThreshold = (value) => {
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 1_000_000) {
|
|
65
|
+
throw new TypeError("event.alertThreshold must be a positive integer no greater than 1000000");
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
};
|
|
60
69
|
export function createWotchi(config) {
|
|
61
70
|
const normalized = validateConfig(config);
|
|
62
71
|
const now = Date.now;
|
|
@@ -73,7 +82,7 @@ export function createWotchi(config) {
|
|
|
73
82
|
});
|
|
74
83
|
const privacy = normalized.privacy;
|
|
75
84
|
let eventSequence = 0;
|
|
76
|
-
const captureSafeEvent = (error, metadata, context, request, eventKind = "error") => {
|
|
85
|
+
const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold) => {
|
|
77
86
|
const timestamp = now();
|
|
78
87
|
const safeError = redactError(normalizeError(error, privacy), privacy);
|
|
79
88
|
const requestContext = safeRequest(request, privacy);
|
|
@@ -94,7 +103,7 @@ export function createWotchi(config) {
|
|
|
94
103
|
const decision = evaluateIncidentPolicy({
|
|
95
104
|
group,
|
|
96
105
|
now: timestamp,
|
|
97
|
-
alertThreshold
|
|
106
|
+
alertThreshold,
|
|
98
107
|
cooldownMs: normalized.grouping.cooldownMs,
|
|
99
108
|
eventKind,
|
|
100
109
|
});
|
|
@@ -128,7 +137,8 @@ export function createWotchi(config) {
|
|
|
128
137
|
if (typeof event.message !== "string") {
|
|
129
138
|
throw new TypeError("event.message must be a string");
|
|
130
139
|
}
|
|
131
|
-
|
|
140
|
+
const alertThreshold = normalizeEventAlertThreshold(event.alertThreshold);
|
|
141
|
+
captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error", alertThreshold ?? normalized.grouping.alertThreshold);
|
|
132
142
|
}
|
|
133
143
|
catch {
|
|
134
144
|
diagnostics.captureFailures += 1;
|
package/dist/esm/core/redact.js
CHANGED
|
@@ -46,11 +46,40 @@ const redactCardLikeValues = (value) => value.replace(/\b\d[\d -]{11,25}\d\b/g,
|
|
|
46
46
|
const digits = candidate.replace(/[ -]/g, "");
|
|
47
47
|
return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? REDACTED : candidate;
|
|
48
48
|
});
|
|
49
|
+
const CONNECTION_URL_PATTERN = /\b(?:postgres(?:ql)?|rediss?|mongodb(?:\+srv)?):\/\/[^\s"'<>]+/gi;
|
|
50
|
+
const CONNECTION_QUERY_SECRET_PATTERN = /([?&](?:password|passwd|pass|secret|token|api[-_]?key|access[-_]?token|refresh[-_]?token|sslpassword)=)[^&#\s]*/gi;
|
|
51
|
+
const URL_TRAILING_PUNCTUATION = /[),.;!?]+$/;
|
|
52
|
+
const redactConnectionUrl = (candidate) => {
|
|
53
|
+
let core = candidate;
|
|
54
|
+
let suffix = "";
|
|
55
|
+
while (URL_TRAILING_PUNCTUATION.test(core)) {
|
|
56
|
+
suffix = `${core.slice(-1)}${suffix}`;
|
|
57
|
+
core = core.slice(0, -1);
|
|
58
|
+
}
|
|
59
|
+
const schemeEnd = core.indexOf("://");
|
|
60
|
+
if (schemeEnd < 0) {
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
const authorityStart = schemeEnd + 3;
|
|
64
|
+
const authorityOffset = core.slice(authorityStart).search(/[/?#]/);
|
|
65
|
+
const authorityEnd = authorityOffset < 0 ? core.length : authorityStart + authorityOffset;
|
|
66
|
+
const authority = core.slice(authorityStart, authorityEnd);
|
|
67
|
+
const atIndex = authority.lastIndexOf("@");
|
|
68
|
+
const redactedAuthority = atIndex < 0 ? authority : `${REDACTED}@${authority.slice(atIndex + 1)}`;
|
|
69
|
+
const remainder = core
|
|
70
|
+
.slice(authorityEnd)
|
|
71
|
+
.replace(CONNECTION_QUERY_SECRET_PATTERN, `$1${REDACTED}`);
|
|
72
|
+
return `${core.slice(0, authorityStart)}${redactedAuthority}${remainder}${suffix}`;
|
|
73
|
+
};
|
|
74
|
+
const redactConnectionUrls = (value) => value.replace(CONNECTION_URL_PATTERN, redactConnectionUrl);
|
|
49
75
|
const redactString = (value, maxStringLength) => {
|
|
50
|
-
let result = value;
|
|
76
|
+
let result = redactConnectionUrls(value);
|
|
51
77
|
result = result.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, REDACTED);
|
|
52
78
|
result = result.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`);
|
|
53
|
-
result = result.replace(/\b(?:authorization|password|passwd|secret|token|api[-_]?key)\s*[:=]\s*[^\s,;]+/gi, (match) =>
|
|
79
|
+
result = result.replace(/\b(?:authorization|password|passwd|secret|token|api[-_]?key)\s*[:=]\s*[^\s,;]+/gi, (match) => {
|
|
80
|
+
const separatorIndex = match.search(/[:=]/);
|
|
81
|
+
return separatorIndex < 0 ? REDACTED : `${match.slice(0, separatorIndex + 1)}${REDACTED}`;
|
|
82
|
+
});
|
|
54
83
|
result = result.replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, REDACTED);
|
|
55
84
|
result = result.replace(/\b(?:github_pat|ghp|xox[baprs]-|sk|pk|AKIA)[A-Za-z0-9_-]{8,}\b/gi, REDACTED);
|
|
56
85
|
result = redactCardLikeValues(result);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getExpressRequestContext } from "./request-context.js";
|
|
2
|
+
import { markExpressErrorCaptured } from "./state.js";
|
|
2
3
|
export function createExpressErrorHandler(client, options) {
|
|
3
4
|
return (error, request, response, next) => {
|
|
5
|
+
markExpressErrorCaptured(request);
|
|
4
6
|
try {
|
|
5
7
|
client.captureException(error, getExpressRequestContext(request, response, options));
|
|
6
8
|
}
|