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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +84 -29
  2. package/SECURITY.md +3 -3
  3. package/dist/cjs/core/admission.js +22 -0
  4. package/dist/cjs/core/client.js +46 -0
  5. package/dist/cjs/core/config.js +16 -0
  6. package/dist/cjs/core/diagnostics.js +4 -0
  7. package/dist/cjs/core/group-store.js +7 -8
  8. package/dist/cjs/core/limits.js +5 -1
  9. package/dist/cjs/core/normalize.js +6 -1
  10. package/dist/cjs/core/notification-queue.js +77 -8
  11. package/dist/cjs/core/prometheus.js +129 -0
  12. package/dist/cjs/core/redact.js +4 -1
  13. package/dist/cjs/core/runtime-monitor.js +109 -0
  14. package/dist/cjs/index.js +6 -1
  15. package/dist/cjs/integrations/express/error-handler.js +24 -6
  16. package/dist/cjs/integrations/express/state.js +7 -20
  17. package/dist/cjs/integrations/express/status-observer.js +3 -9
  18. package/dist/cjs/integrations/nest/capture.js +21 -0
  19. package/dist/cjs/integrations/nest/exception-filter.js +64 -15
  20. package/dist/cjs/integrations/nest/filter-wrapper.js +19 -0
  21. package/dist/cjs/integrations/nest/index.js +6 -1
  22. package/dist/cjs/integrations/nest/module.js +44 -0
  23. package/dist/cjs/integrations/nest/register.js +17 -1
  24. package/dist/cjs/integrations/request-context.js +15 -23
  25. package/dist/esm/core/admission.js +19 -0
  26. package/dist/esm/core/client.js +46 -0
  27. package/dist/esm/core/config.js +17 -1
  28. package/dist/esm/core/diagnostics.js +4 -0
  29. package/dist/esm/core/group-store.js +7 -8
  30. package/dist/esm/core/limits.js +4 -0
  31. package/dist/esm/core/normalize.js +6 -1
  32. package/dist/esm/core/notification-queue.js +78 -9
  33. package/dist/esm/core/prometheus.js +125 -0
  34. package/dist/esm/core/redact.js +4 -1
  35. package/dist/esm/core/runtime-monitor.js +106 -0
  36. package/dist/esm/index.js +3 -1
  37. package/dist/esm/integrations/express/error-handler.js +23 -5
  38. package/dist/esm/integrations/express/state.js +7 -20
  39. package/dist/esm/integrations/express/status-observer.js +3 -9
  40. package/dist/esm/integrations/nest/capture.js +17 -0
  41. package/dist/esm/integrations/nest/exception-filter.js +65 -16
  42. package/dist/esm/integrations/nest/filter-wrapper.js +16 -0
  43. package/dist/esm/integrations/nest/index.js +2 -0
  44. package/dist/esm/integrations/nest/module.js +41 -0
  45. package/dist/esm/integrations/nest/register.js +17 -1
  46. package/dist/esm/integrations/request-context.js +14 -23
  47. package/dist/types/core/admission.d.ts +10 -0
  48. package/dist/types/core/config.d.ts +10 -0
  49. package/dist/types/core/diagnostics.d.ts +3 -1
  50. package/dist/types/core/incident-policy.d.ts +1 -1
  51. package/dist/types/core/limits.d.ts +4 -0
  52. package/dist/types/core/notification-queue.d.ts +9 -0
  53. package/dist/types/core/prometheus.d.ts +7 -0
  54. package/dist/types/core/runtime-monitor.d.ts +15 -0
  55. package/dist/types/core/types.d.ts +16 -1
  56. package/dist/types/index.d.ts +5 -1
  57. package/dist/types/integrations/express/status-observer.d.ts +1 -1
  58. package/dist/types/integrations/nest/capture.d.ts +4 -0
  59. package/dist/types/integrations/nest/exception-filter.d.ts +7 -3
  60. package/dist/types/integrations/nest/filter-wrapper.d.ts +7 -0
  61. package/dist/types/integrations/nest/index.d.ts +4 -0
  62. package/dist/types/integrations/nest/module.d.ts +9 -0
  63. package/dist/types/integrations/nest/register.d.ts +3 -0
  64. package/dist/types/integrations/request-context.d.ts +1 -0
  65. package/dist/types-cjs/core/admission.d.cts +10 -0
  66. package/dist/types-cjs/core/client.d.cts +1 -1
  67. package/dist/types-cjs/core/config.d.cts +12 -2
  68. package/dist/types-cjs/core/diagnostics.d.cts +4 -2
  69. package/dist/types-cjs/core/fingerprint.d.cts +1 -1
  70. package/dist/types-cjs/core/group-store.d.cts +1 -1
  71. package/dist/types-cjs/core/incident-builder.d.cts +2 -2
  72. package/dist/types-cjs/core/incident-policy.d.cts +2 -2
  73. package/dist/types-cjs/core/limits.d.cts +4 -0
  74. package/dist/types-cjs/core/notification-queue.d.cts +10 -1
  75. package/dist/types-cjs/core/process-monitor.d.cts +1 -1
  76. package/dist/types-cjs/core/prometheus.d.cts +7 -0
  77. package/dist/types-cjs/core/redact.d.cts +1 -1
  78. package/dist/types-cjs/core/runtime-monitor.d.cts +15 -0
  79. package/dist/types-cjs/core/types.d.cts +16 -1
  80. package/dist/types-cjs/index.d.cts +15 -11
  81. package/dist/types-cjs/integrations/express/error-handler.d.cts +2 -2
  82. package/dist/types-cjs/integrations/express/index.d.cts +8 -8
  83. package/dist/types-cjs/integrations/express/request-context.d.cts +2 -2
  84. package/dist/types-cjs/integrations/express/status-observer.d.cts +2 -2
  85. package/dist/types-cjs/integrations/nest/capture.d.cts +4 -0
  86. package/dist/types-cjs/integrations/nest/exception-filter.d.cts +8 -4
  87. package/dist/types-cjs/integrations/nest/filter-wrapper.d.cts +7 -0
  88. package/dist/types-cjs/integrations/nest/index.d.cts +9 -5
  89. package/dist/types-cjs/integrations/nest/module.d.cts +9 -0
  90. package/dist/types-cjs/integrations/nest/register.d.cts +7 -4
  91. package/dist/types-cjs/integrations/nest/request-context.d.cts +2 -2
  92. package/dist/types-cjs/integrations/request-context.d.cts +2 -1
  93. package/dist/types-cjs/notifiers/alert-payload.d.cts +2 -2
  94. package/dist/types-cjs/notifiers/console.d.cts +1 -1
  95. package/dist/types-cjs/notifiers/telegram-format.d.cts +1 -1
  96. package/dist/types-cjs/notifiers/telegram.d.cts +2 -2
  97. package/dist/types-cjs/notifiers/webhook-http.d.cts +1 -1
  98. package/dist/types-cjs/notifiers/webhook.d.cts +2 -2
  99. package/package.json +23 -12
package/README.md CHANGED
@@ -2,20 +2,21 @@
2
2
 
3
3
  > Low-noise error alerts for Node.js services.
4
4
 
5
- > **Status:** Public beta (`0.1.0-beta.5`). Install with the `beta` tag; the API may evolve before the first stable release.
5
+ > **Status:** Release candidate (`1.0.0-rc.1`). Validate it in production-like workloads before the first stable release.
6
6
 
7
7
  [![CI](https://github.com/FutureWindAI/Wotchi/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/FutureWindAI/Wotchi/actions/workflows/ci.yml)
8
8
  [![CodeQL](https://github.com/FutureWindAI/Wotchi/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/FutureWindAI/Wotchi/actions/workflows/codeql.yml)
9
- [![npm](https://img.shields.io/npm/v/%40futurewindai%2Fwotchi?label=npm%20beta)](https://www.npmjs.com/package/@futurewindai/wotchi)
10
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
11
- [![Node.js 18–26](https://img.shields.io/badge/node-18%E2%80%9326-339933?logo=node.js&logoColor=white)](https://nodejs.org/en/about/previous-releases)
9
+ [![npm](https://img.shields.io/npm/v/%40futurewindai%2Fwotchi/next?label=npm%20next)](https://www.npmjs.com/package/@futurewindai/wotchi)
10
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/LICENSE)
11
+ [![Node.js 22.14+](https://img.shields.io/badge/node-%3E%3D22.14.0-339933?logo=node.js&logoColor=white)](https://nodejs.org/en/about/previous-releases)
12
12
 
13
+ [Read the Wotchi story on Medium](https://medium.com/@alkazavr94/wotchi-low-noise-error-alerts-for-node-js-services-83cac7cf3f50)
13
14
  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
15
 
15
16
  ## Quick start
16
17
 
17
18
  ```bash
18
- npm install @futurewindai/wotchi@beta
19
+ npm install @futurewindai/wotchi@next
19
20
  ```
20
21
 
21
22
  ```ts
@@ -61,12 +62,51 @@ notifier.
61
62
  | Bounded capture and queueing | Repeated failures cannot create unbounded in-memory work. |
62
63
  | Redaction before processing | Sensitive values are removed before grouping, logging, or transmission. |
63
64
  | 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
+ | Express 4/5 and NestJS 10/11/12 adapters | Errors are observed while the framework keeps response ownership. |
65
66
  | ESM, CommonJS, and TypeScript types | Use the package with common Node.js module setups. |
66
67
  | Console and optional Telegram notifiers | Start locally or self-host delivery without a Wotchi control plane. |
67
68
  | Generic HTTPS webhook notifier | Route bounded JSON alerts to an existing internal alert destination. |
68
69
  | Actionable context and trace passthrough | Include route, release, request/correlation IDs, operation/job, safe tags, links, and existing trace/span IDs. |
69
70
  | Optional status observation and JSON logs | Observe direct `401`/`403`/`429`/`5xx` responses and emit collector-friendly JSON. |
71
+ | Optional diagnostics exporter | Expose aggregate Wotchi health counters to an existing Prometheus-compatible `/metrics` endpoint. |
72
+ | Optional overload admission | Drop capture work before normalization at a measured rate and emit one sanitized overload signal. |
73
+ | Notifier isolation and shutdown | Bound slow destinations, open circuits after repeated failures, and drain safely during termination. |
74
+ | Optional runtime pressure watcher | Threshold CPU, RSS, heap, event-loop delay, or pending alerts with numeric-only local samples. |
75
+
76
+ ## When to use Wotchi
77
+
78
+ Use Wotchi when an Express or NestJS service needs low-noise, privacy-conscious alerts without
79
+ adding a collector, database, or hosted account. Keep a full observability platform when you need
80
+ durable incident history, cross-replica deduplication, dashboards, traces, or paging workflows.
81
+
82
+ ## Performance evidence
83
+
84
+ The package has zero direct runtime dependencies and keeps capture, grouping, and notification work
85
+ bounded. On a Node.js 22.14/macOS arm64 benchmark run of RC1, capture p95/p99 was
86
+ `0.0354 ms`/`0.0354 ms`, duplicate-storm retained heap was `0.183 MiB`, and the packed artifact was
87
+ about `60.8 KB`. Prometheus rendering p95 was `0.0057 ms`. The queue benchmark admitted `101` alerts while a notifier was blocked, capped
88
+ pending work at `100`, and kept Express and NestJS responses completing. These are reproducible local
89
+ measurements, not production guarantees; repeat `npm run benchmark` and `npm run benchmark:queue`
90
+ on your runtime and workload. See [performance evidence](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/PERFORMANCE.md).
91
+
92
+ The exporter does not open a server or send data. Attach it to a protected endpoint owned
93
+ by the host application:
94
+
95
+ ```ts
96
+ import { createWotchiPrometheusExporter } from "@futurewindai/wotchi";
97
+
98
+ const metrics = createWotchiPrometheusExporter(wotchi);
99
+ app.get("/metrics", (_request, response) => {
100
+ response.setHeader("Content-Type", metrics.contentType);
101
+ response.send(metrics.render());
102
+ });
103
+ ```
104
+
105
+ RC1 also supports opt-in admission control, notifier timeouts/circuits, graceful shutdown, and a
106
+ numeric-only runtime watcher.
107
+
108
+ It emits only aggregate counters and queue/group gauges. Prometheus, Grafana, or another existing
109
+ collector remains responsible for scraping, authentication, retention, and dashboards.
70
110
 
71
111
  ## How it works
72
112
 
@@ -102,15 +142,30 @@ const wotchi = createWotchi({
102
142
  app.use(wotchiErrorHandler(wotchi));
103
143
  ```
104
144
 
105
- NestJS applications register the delegating global filter once after creating the application:
145
+ NestJS applications can register Wotchi from the root module, keeping `main.ts` focused on
146
+ application bootstrap:
106
147
 
107
148
  ```ts
108
- import { registerWotchiNest } from "@futurewindai/wotchi/nest";
109
-
110
- registerWotchiNest(app, wotchi);
149
+ import { Module } from "@nestjs/common";
150
+ import { consoleNotifier } from "@futurewindai/wotchi";
151
+ import { WotchiModule } from "@futurewindai/wotchi/nest";
152
+
153
+ @Module({
154
+ imports: [
155
+ WotchiModule.forRoot({
156
+ service: "orders-api",
157
+ environment: "production",
158
+ notifiers: [consoleNotifier()],
159
+ }),
160
+ ],
161
+ })
162
+ export class AppModule {}
111
163
  ```
112
164
 
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.
165
+ For a custom dependency-injected `APP_FILTER`, keep its response and logging behavior, disable the
166
+ automatic Wotchi filter, and wrap it with `withWotchiNestFilter`. See the [NestJS API](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/API.md#nestjs-entry-point).
167
+
168
+ The package requires Node.js `>=22.14.0` and tests the Node.js 22, 24, and 26 release lines. Use a maintained LTS line—Node.js 22 or 24—for production. Express and NestJS adapters are optional subpath integrations, so applications only load the framework adapter they use.
114
169
 
115
170
  ## Telegram alerts
116
171
 
@@ -140,7 +195,7 @@ function requiredEnv(name: string): string {
140
195
  }
141
196
  ```
142
197
 
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.
198
+ Wotchi does not ship a shared bot token. Delivery is queued outside the request path and sends only the sanitized incident alert. See [configuration](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/CONFIGURATION.md) for notifier and security options.
144
199
 
145
200
  ## HTTPS webhook alerts
146
201
 
@@ -206,7 +261,7 @@ Wotchi keeps grouping and cooldown state in one process. Replicas have independe
206
261
  reset groups, and a serverless instance can terminate before asynchronous delivery completes. An
207
262
  in-process SDK cannot reliably detect an OOM kill, frozen event loop, host failure, or unavailable
208
263
  network. Pair it with an external uptime monitor and keep graceful shutdown explicit; see the
209
- [production recipe](examples/production-recipe/README.md).
264
+ [production recipe](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/examples/production-recipe/README.md).
210
265
 
211
266
  ## Process monitoring
212
267
 
@@ -230,25 +285,25 @@ const monitor = registerWotchiProcessMonitor(wotchi);
230
285
 
231
286
  ## Documentation
232
287
 
233
- - [Getting started](docs/GETTING_STARTED.md)
234
- - [Examples](docs/EXAMPLES.md)
235
- - [API reference](docs/API.md)
236
- - [Configuration](docs/CONFIGURATION.md)
237
- - [Compatibility](docs/COMPATIBILITY.md)
238
- - [Performance](docs/PERFORMANCE.md)
239
- - [Architecture](docs/ARCHITECTURE.md)
240
- - [Roadmap](docs/ROADMAP.md)
241
- - [Troubleshooting](docs/TROUBLESHOOTING.md)
242
- - [FAQ](docs/FAQ.md)
243
- - [Security and privacy](docs/SECURITY.md)
244
- - [Threat model](docs/THREAT_MODEL.md)
245
- - [Contributing](CONTRIBUTING.md)
246
- - [Changelog](CHANGELOG.md)
288
+ - [Getting started](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/GETTING_STARTED.md)
289
+ - [Examples](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/EXAMPLES.md)
290
+ - [API reference](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/API.md)
291
+ - [Configuration](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/CONFIGURATION.md)
292
+ - [Compatibility](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/COMPATIBILITY.md)
293
+ - [Performance](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/PERFORMANCE.md)
294
+ - [Architecture](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/ARCHITECTURE.md)
295
+ - [Roadmap](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/ROADMAP.md)
296
+ - [Troubleshooting](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/TROUBLESHOOTING.md)
297
+ - [FAQ](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/FAQ.md)
298
+ - [Security and privacy](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/SECURITY.md)
299
+ - [Threat model](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/docs/THREAT_MODEL.md)
300
+ - [Contributing](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/CONTRIBUTING.md)
301
+ - [Changelog](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/CHANGELOG.md)
247
302
  - [GitHub releases](https://github.com/FutureWindAI/Wotchi/releases)
248
- - [Apache License 2.0](LICENSE)
303
+ - [Apache License 2.0](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/LICENSE)
249
304
 
250
305
  ## Security
251
306
 
252
- Do not include real secrets or customer error data in issues, examples, or test fixtures. Report security vulnerabilities privately using [SECURITY.md](SECURITY.md).
307
+ Do not include real secrets or customer error data in issues, examples, or test fixtures. Report security vulnerabilities privately using the [security policy](https://github.com/FutureWindAI/Wotchi/blob/v1.0.0-rc.1/SECURITY.md).
253
308
 
254
309
  Wotchi is open source and maintained by FutureWind AI.
package/SECURITY.md CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  ## Supported Versions
4
4
 
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.
5
+ Wotchi targets applications running Node.js `>=22.14.0` and tests the Node.js 22, 24, and 26 release lines. Node.js versions below 22.14 are not supported; use a maintained LTS line for production.
6
6
 
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.
7
+ The current package contains the framework-independent capture core, console, Telegram, and generic HTTPS webhook notifiers, Express 4/5 middleware, NestJS 10/11/12 exception-filter integration, optional HTTP status observation, 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
 
11
11
  Do not disclose a suspected vulnerability, exploit detail, credential, token, or real customer error in a public issue, discussion, pull request, or example.
12
12
 
13
- Use [GitHub private vulnerability reporting](https://github.com/FutureWindAI/wotchi/security/advisories/new) when it is available for this repository. If that private form is unavailable, open a public issue containing no sensitive detail and ask the maintainers to establish a private reporting channel.
13
+ Use [GitHub private vulnerability reporting](https://github.com/FutureWindAI/Wotchi/security/advisories/new). Do not open a public issue for a suspected vulnerability.
14
14
 
15
15
  Include only the minimum sanitized information needed to reproduce the problem. Remove application secrets, personal data, proprietary source, production URLs, and raw customer payloads.
16
16
 
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCaptureAdmission = createCaptureAdmission;
4
+ /** A small token bucket used before normalization to bound overload work. */
5
+ function createCaptureAdmission(options) {
6
+ const now = options.now ?? Date.now;
7
+ let tokens = options.burst;
8
+ let lastRefill = now();
9
+ return {
10
+ tryAcquire: () => {
11
+ const timestamp = now();
12
+ const elapsedMs = Math.max(0, timestamp - lastRefill);
13
+ tokens = Math.min(options.burst, tokens + (elapsedMs * options.maxEventsPerSecond) / 1_000);
14
+ lastRefill = timestamp;
15
+ if (tokens < 1) {
16
+ return false;
17
+ }
18
+ tokens -= 1;
19
+ return true;
20
+ },
21
+ };
22
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createWotchi = createWotchi;
4
4
  const config_js_1 = require("./config.js");
5
+ const admission_js_1 = require("./admission.js");
5
6
  const diagnostics_js_1 = require("./diagnostics.js");
6
7
  const fingerprint_js_1 = require("./fingerprint.js");
7
8
  const group_store_js_1 = require("./group-store.js");
@@ -282,9 +283,19 @@ function createWotchi(config) {
282
283
  const queue = (0, notification_queue_js_1.createNotificationQueue)({
283
284
  maxPendingAlerts: normalized.queue.maxPendingAlerts,
284
285
  concurrency: normalized.queue.concurrency,
286
+ notifierTimeoutMs: normalized.queue.notifierTimeoutMs,
287
+ notifierCircuitBreaker: normalized.queue.notifierCircuitBreaker,
285
288
  });
289
+ const admission = normalized.overload === undefined
290
+ ? undefined
291
+ : (0, admission_js_1.createCaptureAdmission)({
292
+ maxEventsPerSecond: normalized.overload.maxEventsPerSecond,
293
+ burst: normalized.overload.burst,
294
+ now,
295
+ });
286
296
  const privacy = normalized.privacy;
287
297
  let eventSequence = 0;
298
+ let overloadLastAlertAt = 0;
288
299
  const matchingRule = (event) => normalized.rules.find((rule) => (rule.environment === undefined || rule.environment === event.environment) &&
289
300
  (rule.route === undefined || rule.route === event.request?.route));
290
301
  const captureSafeEvent = (error, metadata, context, request, eventKind = "error", alertThreshold = normalized.grouping.alertThreshold, fingerprintOverride, severityOverride, trace, correlationId, operation, job, tags) => {
@@ -405,11 +416,28 @@ function createWotchi(config) {
405
416
  }
406
417
  diagnostics.capturedEvents += 1;
407
418
  };
419
+ const emitOverloadSignal = (timestamp) => {
420
+ const cooldownMs = normalized.overload?.alertCooldownMs ?? 0;
421
+ if (cooldownMs > 0 && timestamp - overloadLastAlertAt < cooldownMs) {
422
+ return;
423
+ }
424
+ overloadLastAlertAt = timestamp;
425
+ captureSafeEvent(new Error("Wotchi capture overload: admission limit reached"), undefined, { droppedEvents: diagnostics.eventsDroppedOverload }, undefined, "runtime-monitor", 1, "wotchi.capture.overload", "high", undefined, undefined, "wotchi.overload", undefined, undefined);
426
+ };
408
427
  const captureException = (error, context, options) => {
409
428
  if (!normalized.enabled) {
410
429
  return;
411
430
  }
431
+ if (queue.isClosed()) {
432
+ diagnostics.capturesAfterShutdown += 1;
433
+ return;
434
+ }
412
435
  try {
436
+ if (admission !== undefined && !admission.tryAcquire()) {
437
+ diagnostics.eventsDroppedOverload += 1;
438
+ emitOverloadSignal(now());
439
+ return;
440
+ }
413
441
  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);
414
442
  }
415
443
  catch {
@@ -420,6 +448,10 @@ function createWotchi(config) {
420
448
  if (!normalized.enabled) {
421
449
  return;
422
450
  }
451
+ if (queue.isClosed()) {
452
+ diagnostics.capturesAfterShutdown += 1;
453
+ return;
454
+ }
423
455
  try {
424
456
  if (event === null || typeof event !== "object" || event.level !== "error") {
425
457
  throw new TypeError("event.level must be error");
@@ -428,6 +460,14 @@ function createWotchi(config) {
428
460
  throw new TypeError("event.message must be a string");
429
461
  }
430
462
  const alertThreshold = normalizeEventAlertThreshold(event.alertThreshold);
463
+ if (admission !== undefined &&
464
+ event.kind !== "process-monitor" &&
465
+ event.kind !== "runtime-monitor" &&
466
+ !admission.tryAcquire()) {
467
+ diagnostics.eventsDroppedOverload += 1;
468
+ emitOverloadSignal(now());
469
+ return;
470
+ }
431
471
  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);
432
472
  }
433
473
  catch {
@@ -440,6 +480,8 @@ function createWotchi(config) {
440
480
  alertsDropped: queue.alertsDropped(),
441
481
  alertsSent: queue.alertsSent(),
442
482
  notifierFailures: queue.notifierFailures(),
483
+ notifierTimeouts: queue.notifierTimeouts(),
484
+ notifierCircuitOpenSkips: queue.notifierCircuitOpenSkips(),
443
485
  activeGroups: groupStore.size(),
444
486
  pendingAlerts: queue.pending(),
445
487
  });
@@ -503,11 +545,15 @@ function createWotchi(config) {
503
545
  ...(status === "notifier-failed" ? { error: "One or more notifiers failed." } : {}),
504
546
  };
505
547
  };
548
+ const shutdown = async (timeoutMs) => {
549
+ await queue.close(timeoutMs);
550
+ };
506
551
  return {
507
552
  captureException,
508
553
  captureEvent,
509
554
  testAlert,
510
555
  flush: (timeoutMs) => queue.flush(timeoutMs),
556
+ shutdown,
511
557
  getDiagnostics: readDiagnostics,
512
558
  };
513
559
  }
@@ -232,7 +232,9 @@ function validateConfig(config) {
232
232
  }
233
233
  const grouping = readOptionalRecord(config.grouping, "grouping");
234
234
  const queue = readOptionalRecord(config.queue, "queue");
235
+ const notifierCircuitBreaker = readOptionalRecord(queue?.notifierCircuitBreaker, "queue.notifierCircuitBreaker");
235
236
  const privacy = readOptionalRecord(config.privacy, "privacy");
237
+ const overload = readOptionalRecord(config.overload, "overload");
236
238
  const links = normalizeLinks(config.links);
237
239
  const concurrency = queue?.concurrency;
238
240
  if (concurrency !== undefined && concurrency !== 1) {
@@ -259,7 +261,21 @@ function validateConfig(config) {
259
261
  queue: Object.freeze({
260
262
  maxPendingAlerts: readPositiveInteger(queue, "maxPendingAlerts", 100, limits_js_1.MAX_PENDING_ALERTS),
261
263
  concurrency: 1,
264
+ notifierTimeoutMs: readPositiveInteger(queue, "notifierTimeoutMs", 5_000, limits_js_1.MAX_NOTIFIER_TIMEOUT_MS),
265
+ notifierCircuitBreaker: Object.freeze({
266
+ failureThreshold: readPositiveInteger(notifierCircuitBreaker, "failureThreshold", 3, limits_js_1.MAX_CIRCUIT_BREAKER_FAILURES),
267
+ cooldownMs: readPositiveInteger(notifierCircuitBreaker, "cooldownMs", 30_000, limits_js_1.MAX_CIRCUIT_BREAKER_COOLDOWN_MS),
268
+ }),
262
269
  }),
270
+ ...(overload === undefined
271
+ ? {}
272
+ : {
273
+ overload: Object.freeze({
274
+ maxEventsPerSecond: readPositiveInteger(overload, "maxEventsPerSecond", 1_000, limits_js_1.MAX_CAPTURE_RATE),
275
+ burst: readPositiveInteger(overload, "burst", readPositiveInteger(overload, "maxEventsPerSecond", 1_000, limits_js_1.MAX_CAPTURE_RATE), limits_js_1.MAX_CAPTURE_RATE),
276
+ alertCooldownMs: readPositiveInteger(overload, "alertCooldownMs", 60_000, limits_js_1.MAX_COOLDOWN_MS),
277
+ }),
278
+ }),
263
279
  privacy: Object.freeze({
264
280
  redactKeys: normalizeRedactKeys(privacy),
265
281
  maxDepth: readPositiveInteger(privacy, "maxDepth", 5, limits_js_1.MAX_NORMALIZATION_DEPTH),
@@ -10,6 +10,8 @@ function createDiagnosticsState() {
10
10
  filterFailures: 0,
11
11
  beforeSendFailures: 0,
12
12
  eventsSuppressed: 0,
13
+ eventsDroppedOverload: 0,
14
+ capturesAfterShutdown: 0,
13
15
  };
14
16
  }
15
17
  function snapshotDiagnostics(state, values) {
@@ -20,6 +22,8 @@ function snapshotDiagnostics(state, values) {
20
22
  filterFailures: state.filterFailures,
21
23
  beforeSendFailures: state.beforeSendFailures,
22
24
  eventsSuppressed: state.eventsSuppressed,
25
+ eventsDroppedOverload: state.eventsDroppedOverload,
26
+ capturesAfterShutdown: state.capturesAfterShutdown,
23
27
  ...values,
24
28
  });
25
29
  }
@@ -50,14 +50,9 @@ function createGroupStore(options) {
50
50
  severity: group.severity,
51
51
  });
52
52
  const evictLeastRecent = () => {
53
- let candidate;
54
- for (const group of groups.values()) {
55
- if (candidate === undefined || group.lastSeenMs < candidate.lastSeenMs) {
56
- candidate = group;
57
- }
58
- }
59
- if (candidate !== undefined) {
60
- groups.delete(candidate.fingerprint);
53
+ const oldestFingerprint = groups.keys().next().value;
54
+ if (typeof oldestFingerprint === "string") {
55
+ groups.delete(oldestFingerprint);
61
56
  evicted += 1;
62
57
  }
63
58
  };
@@ -83,6 +78,10 @@ function createGroupStore(options) {
83
78
  };
84
79
  groups.set(fingerprint, group);
85
80
  }
81
+ else {
82
+ groups.delete(fingerprint);
83
+ groups.set(fingerprint, group);
84
+ }
86
85
  group.lastSeenMs = timestamp;
87
86
  group.totalCount += 1;
88
87
  group.severity = severity;
@@ -1,12 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_NORMALIZATION_STACK_LENGTH = exports.MAX_NORMALIZATION_STRING_LENGTH = exports.MAX_NORMALIZATION_KEYS = exports.MAX_NORMALIZATION_DEPTH = exports.MAX_COOLDOWN_MS = exports.MAX_ALERT_THRESHOLD = exports.MAX_WINDOW_MS = exports.MAX_PENDING_ALERTS = exports.MAX_EVENTS_PER_WINDOW = exports.MAX_GROUPS = void 0;
3
+ exports.MAX_NORMALIZATION_STACK_LENGTH = exports.MAX_NORMALIZATION_STRING_LENGTH = exports.MAX_NORMALIZATION_KEYS = exports.MAX_NORMALIZATION_DEPTH = exports.MAX_CIRCUIT_BREAKER_FAILURES = exports.MAX_CIRCUIT_BREAKER_COOLDOWN_MS = exports.MAX_NOTIFIER_TIMEOUT_MS = exports.MAX_CAPTURE_RATE = exports.MAX_COOLDOWN_MS = exports.MAX_ALERT_THRESHOLD = exports.MAX_WINDOW_MS = exports.MAX_PENDING_ALERTS = exports.MAX_EVENTS_PER_WINDOW = exports.MAX_GROUPS = void 0;
4
4
  exports.MAX_GROUPS = 10_000;
5
5
  exports.MAX_EVENTS_PER_WINDOW = 10_000;
6
6
  exports.MAX_PENDING_ALERTS = 10_000;
7
7
  exports.MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1_000;
8
8
  exports.MAX_ALERT_THRESHOLD = 1_000_000;
9
9
  exports.MAX_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1_000;
10
+ exports.MAX_CAPTURE_RATE = 1_000_000;
11
+ exports.MAX_NOTIFIER_TIMEOUT_MS = 60_000;
12
+ exports.MAX_CIRCUIT_BREAKER_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
13
+ exports.MAX_CIRCUIT_BREAKER_FAILURES = 100;
10
14
  exports.MAX_NORMALIZATION_DEPTH = 20;
11
15
  exports.MAX_NORMALIZATION_KEYS = 10_000;
12
16
  exports.MAX_NORMALIZATION_STRING_LENGTH = 32_768;
@@ -98,7 +98,12 @@ const normalizeValue = (value, depth, state, limits) => {
98
98
  break;
99
99
  }
100
100
  state.keysVisited += 1;
101
- result[truncate(key, limits.maxStringLength)] = normalizeValue(readProperty(value, key), depth + 1, state, limits);
101
+ const child = readProperty(value, key);
102
+ const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
103
+ result[truncate(key, limits.maxStringLength)] =
104
+ normalizedKey === "stack" && typeof child === "string"
105
+ ? truncate(child, limits.maxStackLength)
106
+ : normalizeValue(child, depth + 1, state, limits);
102
107
  }
103
108
  return result;
104
109
  };
@@ -3,6 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createNotificationQueue = createNotificationQueue;
4
4
  const limits_js_1 = require("./limits.js");
5
5
  const DEFAULT_FLUSH_TIMEOUT_MS = 5_000;
6
+ const DEFAULT_NOTIFIER_TIMEOUT_MS = 5_000;
7
+ const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
8
+ const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
6
9
  function createNotificationQueue(options) {
7
10
  if (!Number.isSafeInteger(options.maxPendingAlerts) ||
8
11
  options.maxPendingAlerts <= 0 ||
@@ -12,6 +15,23 @@ function createNotificationQueue(options) {
12
15
  if (options.concurrency !== 1) {
13
16
  throw new RangeError("concurrency must be 1");
14
17
  }
18
+ if (options.notifierTimeoutMs !== undefined &&
19
+ (!Number.isSafeInteger(options.notifierTimeoutMs) ||
20
+ options.notifierTimeoutMs <= 0 ||
21
+ options.notifierTimeoutMs > limits_js_1.MAX_NOTIFIER_TIMEOUT_MS)) {
22
+ throw new RangeError("notifierTimeoutMs must be a positive integer");
23
+ }
24
+ if (options.notifierCircuitBreaker !== undefined) {
25
+ const { failureThreshold, cooldownMs } = options.notifierCircuitBreaker;
26
+ if (!Number.isSafeInteger(failureThreshold) ||
27
+ failureThreshold <= 0 ||
28
+ failureThreshold > limits_js_1.MAX_CIRCUIT_BREAKER_FAILURES ||
29
+ !Number.isSafeInteger(cooldownMs) ||
30
+ cooldownMs <= 0 ||
31
+ cooldownMs > limits_js_1.MAX_CIRCUIT_BREAKER_COOLDOWN_MS) {
32
+ throw new RangeError("notifierCircuitBreaker limits are invalid");
33
+ }
34
+ }
15
35
  const pendingJobs = [];
16
36
  let drainPromise;
17
37
  let resolveDrain;
@@ -20,6 +40,15 @@ function createNotificationQueue(options) {
20
40
  let dropped = 0;
21
41
  let sent = 0;
22
42
  let failures = 0;
43
+ let timeouts = 0;
44
+ let circuitOpenSkips = 0;
45
+ let closed = false;
46
+ const notifierStates = new Map();
47
+ const notifierTimeoutMs = options.notifierTimeoutMs ?? DEFAULT_NOTIFIER_TIMEOUT_MS;
48
+ const circuit = options.notifierCircuitBreaker ?? {
49
+ failureThreshold: DEFAULT_CIRCUIT_FAILURE_THRESHOLD,
50
+ cooldownMs: DEFAULT_CIRCUIT_COOLDOWN_MS,
51
+ };
23
52
  const notifyFailure = (error, notifier) => {
24
53
  failures += 1;
25
54
  try {
@@ -38,19 +67,51 @@ function createNotificationQueue(options) {
38
67
  resolveDrain = undefined;
39
68
  };
40
69
  const processJob = async (job) => {
41
- let jobSent = 0;
42
- let jobFailures = 0;
43
- for (const notifier of job.notifiers) {
70
+ const results = await Promise.all(job.notifiers.map(async (notifier) => {
71
+ const state = notifierStates.get(notifier) ?? { failures: 0, openUntil: 0 };
72
+ const timestamp = Date.now();
73
+ if (state.openUntil > timestamp) {
74
+ circuitOpenSkips += 1;
75
+ notifyFailure(new Error(`Notifier circuit is open: ${notifier.name}`), notifier);
76
+ return { sent: 0, failed: 1 };
77
+ }
78
+ if (state.openUntil !== 0) {
79
+ state.openUntil = 0;
80
+ state.failures = 0;
81
+ }
82
+ let timer;
44
83
  try {
45
- await notifier.send(job.alert);
84
+ const timeout = new Promise((_, reject) => {
85
+ timer = setTimeout(() => {
86
+ timeouts += 1;
87
+ reject(new Error(`Notifier timed out after ${notifierTimeoutMs}ms: ${notifier.name}`));
88
+ }, notifierTimeoutMs);
89
+ timer.unref?.();
90
+ });
91
+ await Promise.race([notifier.send(job.alert), timeout]);
92
+ if (timer !== undefined) {
93
+ clearTimeout(timer);
94
+ }
95
+ state.failures = 0;
96
+ notifierStates.set(notifier, state);
46
97
  sent += 1;
47
- jobSent += 1;
98
+ return { sent: 1, failed: 0 };
48
99
  }
49
100
  catch (error) {
50
- jobFailures += 1;
101
+ if (timer !== undefined) {
102
+ clearTimeout(timer);
103
+ }
104
+ state.failures += 1;
105
+ if (state.failures >= circuit.failureThreshold) {
106
+ state.openUntil = Date.now() + circuit.cooldownMs;
107
+ }
108
+ notifierStates.set(notifier, state);
51
109
  notifyFailure(error, notifier);
110
+ return { sent: 0, failed: 1 };
52
111
  }
53
- }
112
+ }));
113
+ const jobSent = results.reduce((total, result) => total + result.sent, 0);
114
+ const jobFailures = results.reduce((total, result) => total + result.failed, 0);
54
115
  try {
55
116
  job.onComplete?.({ notifierFailures: jobFailures, sent: jobSent });
56
117
  }
@@ -73,7 +134,7 @@ function createNotificationQueue(options) {
73
134
  }
74
135
  };
75
136
  const enqueue = (alert, notifiers, onComplete) => {
76
- if (pendingJobs.length >= options.maxPendingAlerts) {
137
+ if (closed || pendingJobs.length >= options.maxPendingAlerts) {
77
138
  dropped += 1;
78
139
  return false;
79
140
  }
@@ -86,6 +147,10 @@ function createNotificationQueue(options) {
86
147
  pump();
87
148
  return true;
88
149
  };
150
+ const close = (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) => {
151
+ closed = true;
152
+ return flush(timeoutMs);
153
+ };
89
154
  const flush = (timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) => {
90
155
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
91
156
  return Promise.reject(new RangeError("timeoutMs must be a positive integer"));
@@ -111,10 +176,14 @@ function createNotificationQueue(options) {
111
176
  return {
112
177
  enqueue,
113
178
  flush,
179
+ close,
180
+ isClosed: () => closed,
114
181
  pending: () => pendingJobs.length,
115
182
  alertsQueued: () => queued,
116
183
  alertsDropped: () => dropped,
117
184
  alertsSent: () => sent,
118
185
  notifierFailures: () => failures,
186
+ notifierTimeouts: () => timeouts,
187
+ notifierCircuitOpenSkips: () => circuitOpenSkips,
119
188
  };
120
189
  }