@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.
Files changed (58) hide show
  1. package/README.md +110 -61
  2. package/SECURITY.md +2 -2
  3. package/dist/cjs/core/client.js +13 -3
  4. package/dist/cjs/core/redact.js +31 -2
  5. package/dist/cjs/integrations/express/error-handler.js +2 -0
  6. package/dist/cjs/integrations/express/index.js +3 -1
  7. package/dist/cjs/integrations/express/state.js +30 -0
  8. package/dist/cjs/integrations/express/status-observer.js +80 -0
  9. package/dist/cjs/integrations/nest/index.js +2 -1
  10. package/dist/cjs/integrations/nest/register.js +9 -0
  11. package/dist/cjs/notifiers/console.js +32 -12
  12. package/dist/esm/core/client.js +13 -3
  13. package/dist/esm/core/redact.js +31 -2
  14. package/dist/esm/integrations/express/error-handler.js +2 -0
  15. package/dist/esm/integrations/express/index.js +1 -0
  16. package/dist/esm/integrations/express/state.js +26 -0
  17. package/dist/esm/integrations/express/status-observer.js +77 -0
  18. package/dist/esm/integrations/nest/index.js +1 -1
  19. package/dist/esm/integrations/nest/register.js +8 -0
  20. package/dist/esm/notifiers/console.js +32 -12
  21. package/dist/types/core/types.d.ts +2 -0
  22. package/dist/types/integrations/express/index.d.ts +2 -0
  23. package/dist/types/integrations/express/state.d.ts +2 -0
  24. package/dist/types/integrations/express/status-observer.d.ts +11 -0
  25. package/dist/types/integrations/nest/index.d.ts +2 -1
  26. package/dist/types/integrations/nest/register.d.ts +3 -0
  27. package/dist/types/notifiers/console.d.ts +1 -1
  28. package/dist/types-cjs/core/client.d.cts +2 -0
  29. package/dist/types-cjs/core/config.d.cts +29 -0
  30. package/dist/types-cjs/core/diagnostics.d.cts +7 -0
  31. package/dist/types-cjs/core/errors.d.cts +3 -0
  32. package/dist/types-cjs/core/fingerprint.d.cts +3 -0
  33. package/dist/types-cjs/core/group-store.d.cts +15 -0
  34. package/dist/types-cjs/core/incident-builder.d.cts +3 -0
  35. package/dist/types-cjs/core/incident-policy.d.cts +15 -0
  36. package/dist/types-cjs/core/normalize.d.cts +17 -0
  37. package/dist/types-cjs/core/notification-queue.d.cts +16 -0
  38. package/dist/types-cjs/core/process-monitor.d.cts +5 -0
  39. package/dist/types-cjs/core/redact.d.cts +11 -0
  40. package/dist/types-cjs/core/rolling-window.d.cts +12 -0
  41. package/dist/types-cjs/core/stack-frame.d.cts +2 -0
  42. package/dist/types-cjs/core/types.d.cts +111 -0
  43. package/dist/types-cjs/index.d.cts +9 -0
  44. package/dist/types-cjs/integrations/express/error-handler.d.cts +4 -0
  45. package/dist/types-cjs/integrations/express/index.d.cts +9 -0
  46. package/dist/types-cjs/integrations/express/request-context.d.cts +5 -0
  47. package/dist/types-cjs/integrations/express/state.d.cts +2 -0
  48. package/dist/types-cjs/integrations/express/status-observer.d.cts +11 -0
  49. package/dist/types-cjs/integrations/nest/exception-filter.d.cts +10 -0
  50. package/dist/types-cjs/integrations/nest/index.d.cts +5 -0
  51. package/dist/types-cjs/integrations/nest/register.d.cts +13 -0
  52. package/dist/types-cjs/integrations/nest/request-context.d.cts +5 -0
  53. package/dist/types-cjs/integrations/request-context.d.cts +13 -0
  54. package/dist/types-cjs/notifiers/console.d.cts +3 -0
  55. package/dist/types-cjs/notifiers/telegram-format.d.cts +2 -0
  56. package/dist/types-cjs/notifiers/telegram-http.d.cts +21 -0
  57. package/dist/types-cjs/notifiers/telegram.d.cts +4 -0
  58. package/package.json +26 -6
package/README.md CHANGED
@@ -1,26 +1,86 @@
1
1
  # Wotchi
2
2
 
3
- > Your production's night shift.
3
+ > Bounded incident alerts for Node.js services.
4
4
 
5
- Wotchi is a lightweight incident assistant for Node.js applications. It is intended to help small teams turn repeated Express and NestJS application errors into a small number of useful console or Telegram alerts without operating a separate monitoring stack.
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
- > **Project status:** Private beta candidate (`0.1.0-beta.1`). Phases 1–8 are implemented and locally validated, including bounded incident processing, console and Telegram alerts, Express 4/5 middleware, NestJS 10/11 exception-filter integration, opt-in crash observation, security gates, performance budgets, packed-tarball examples, and Node.js 18–26 CI. The source repository is public; the npm package is not published yet.
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
+ [![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.18](https://img.shields.io/badge/node-%3E%3D18.18.0-339933?logo=node.js&logoColor=white)](https://nodejs.org/en/about/previous-releases)
8
12
 
9
- The working package name is `@futurewindai/wotchi`. The founder accepted the documented Wotchi trademark risk for the beta release on 2026-08-08; this is not legal clearance. The package remains unpublished until the exact release snapshot and gates are verified.
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
- ## Planned v0.1 Scope
15
+ ## Quick start
12
16
 
13
- - one framework-independent TypeScript core;
14
- - focused integrations for Express 4/5 and NestJS 10/11;
15
- - manual exception and structured error-event capture;
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
- The package exposes a root entry point plus focused `/express` and `/nest` entry points. Framework adapters are loaded only through their subpaths, and they observe errors while leaving response ownership with Express or NestJS.
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
- Telegram is an optional self-hosted notifier. The application owner creates a bot with BotFather, starts or adds it to the destination chat, and keeps the credentials outside source control:
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. Telegram delivery is queued outside the request path, uses bounded HTTPS timeouts/retries, and sends only the sanitized incident alert.
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 also opt-in:
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
- The implemented core can be exercised directly:
145
+ ## What it is not
80
146
 
81
- ```ts
82
- import { consoleNotifier, createWotchi } from "@futurewindai/wotchi";
83
-
84
- const wotchi = createWotchi({
85
- service: "orders-api",
86
- environment: "production",
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
- ## Not Planned for v0.1
154
+ ## Documentation
105
155
 
106
- - a full observability, APM, or log-management platform;
107
- - a hosted dashboard, collector, or persistent incident history;
108
- - AI-generated summaries;
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
- - [Testing and smoke validation](docs/TESTING.md)
121
- - [Security and privacy notes](docs/SECURITY.md)
122
- - [Development workflow](docs/DEVELOPMENT.md)
123
- - [Security policy](SECURITY.md)
124
- - [Contributing guide](CONTRIBUTING.md)
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
- Wotchi is an early open-source project from FutureWind AI. Contributions and product feedback should stay within the documented scope and avoid real secrets or customer error data.
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 is currently pre-release. The package targets consumers running Node.js `>=18.18.0` and is built and tested across the maintained Node.js 18–26 matrix. Node.js versions below 18.18 are not supported.
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 pre-release 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. The package has not been published to npm. Supported runtime and framework combinations are documented only after the packed-tarball compatibility and release-security gates pass.
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
 
@@ -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: normalized.grouping.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
- captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error");
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;
@@ -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) => `${match.slice(0, match.search(/[:=]/))}: ${exports.REDACTED}`);
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
- function formatConsoleAlert(alert) {
9
- const actions = alert.suggestedActions
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) => `- ${limit(action, 300)}`)
12
- .join("\n");
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
- limit(alert.title),
15
- `Service: ${limit(alert.service, 300)}`,
16
- `Environment: ${limit(alert.environment, 300)}`,
17
- `Summary: ${limit(alert.summary)}`,
18
- `Occurrences: ${alert.occurrences}`,
19
- `First seen: ${limit(alert.firstSeenAt, 100)}`,
20
- `Last seen: ${limit(alert.lastSeenAt, 100)}`,
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
  }
@@ -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: normalized.grouping.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
- captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error");
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;
@@ -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) => `${match.slice(0, match.search(/[:=]/))}: ${REDACTED}`);
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
  }