node-observe 0.1.0
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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/index.cjs +466 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +273 -0
- package/dist/index.d.ts +273 -0
- package/dist/index.js +454 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 node-observe contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# node-observe
|
|
2
|
+
|
|
3
|
+
**Datadog-lite backend observability for Node.js.** No agent, no SaaS bill, minimal dependencies — drop it into an Express + Mongo + Redis app and get latency percentiles, query timings, memory-leak detection, and Slack alerts in a few lines.
|
|
4
|
+
|
|
5
|
+
Built for small teams who don't (yet) have an APM but still need to know *what's slow* and *when something's leaking*.
|
|
6
|
+
|
|
7
|
+
- 🌐 **API latency tracking** — per-route p50/p95/p99, error rates, status-class counters
|
|
8
|
+
- 🍃 **MongoDB query timing** — every Mongoose query/aggregate/save, by `Model.op`
|
|
9
|
+
- 🧱 **Redis timing** — every ioredis command, by command name
|
|
10
|
+
- 📈 **Memory-leak detection** — least-squares trend on heap-used (MB/min), not noisy single readings
|
|
11
|
+
- 🔔 **Slack alerts** — threshold breaches pushed to a webhook, with cooldown de-duplication
|
|
12
|
+
- 📊 **`/insights` snapshot** — one JSON endpoint with everything
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install node-observe
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> Node ≥ 18 (uses global `fetch` for Slack). ESM + CJS, fully typed. `express`, `mongoose`, and `ioredis` are **optional** — node-observe duck-types whatever you pass it, so it depends on none of them.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import express from "express";
|
|
26
|
+
import mongoose from "mongoose";
|
|
27
|
+
import Redis from "ioredis";
|
|
28
|
+
import { Insights } from "node-observe";
|
|
29
|
+
|
|
30
|
+
const insights = new Insights({
|
|
31
|
+
serviceName: "api",
|
|
32
|
+
slackWebhook: process.env.SLACK_WEBHOOK_URL, // optional
|
|
33
|
+
thresholds: {
|
|
34
|
+
requestMs: 1000, // alert on requests slower than 1s
|
|
35
|
+
mongoMs: 300,
|
|
36
|
+
redisMs: 100,
|
|
37
|
+
heapGrowthMbPerMin: 50,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const app = express();
|
|
42
|
+
|
|
43
|
+
app.use(insights.express()); // 1. time every request
|
|
44
|
+
insights.instrumentMongoose(mongoose); // 2. time every Mongo op (call before models compile)
|
|
45
|
+
insights.instrumentRedis(new Redis()); // 3. time every Redis command
|
|
46
|
+
insights.startMemoryMonitor(); // 4. watch the heap for leaks
|
|
47
|
+
|
|
48
|
+
app.get("/insights", insights.handler()); // 5. expose the JSON snapshot
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That's it. Hit `/insights` and you'll see something like:
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
{
|
|
55
|
+
"service": "api",
|
|
56
|
+
"uptimeSec": 3600,
|
|
57
|
+
"http": {
|
|
58
|
+
"GET /users/:id": { "count": 1240, "errors": 3, "mean": 42.1, "p95": 88.0, "p99": 210.5, ... },
|
|
59
|
+
"POST /posts": { "count": 95, "errors": 0, "mean": 120.4, "p95": 305.0, ... }
|
|
60
|
+
},
|
|
61
|
+
"mongo": { "User.findOne": { "count": 2100, "p95": 12.0, ... }, "Post.aggregate": { "p95": 240.0, ... } },
|
|
62
|
+
"redis": { "get": { "count": 8800, "p95": 1.2 }, "hgetall": { "p95": 3.0 } },
|
|
63
|
+
"counters": { "http.status.2xx": 1300, "http.status.5xx": 3 },
|
|
64
|
+
"memory": { "heapUsedMb": 180.2, "rssMb": 320.5, "growthMbPerMin": 1.4 }
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Alerts
|
|
71
|
+
|
|
72
|
+
When a metric crosses its threshold, node-observe raises an `Alert`. Each alert is de-duplicated per key for `alertCooldownMs` (default 60s) so you don't get spammed.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const insights = new Insights({
|
|
76
|
+
slackWebhook: process.env.SLACK_WEBHOOK_URL,
|
|
77
|
+
onAlert: (alert) => {
|
|
78
|
+
// also forward anywhere you like: logs, PagerDuty, etc.
|
|
79
|
+
logger.warn(`[${alert.type}] ${alert.message}`);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Alert types: `slow-request`, `slow-mongo`, `slow-redis`, `memory-leak`, `memory-high`.
|
|
85
|
+
|
|
86
|
+
A Slack alert looks like:
|
|
87
|
+
|
|
88
|
+
> 🐢 **[api] slow-request**
|
|
89
|
+
> `GET /users/:id` took 1820ms
|
|
90
|
+
> • value: `1820` • threshold: `1000` • at: 2026-06-08T12:00:00.000Z
|
|
91
|
+
|
|
92
|
+
### Memory-leak detection
|
|
93
|
+
|
|
94
|
+
`startMemoryMonitor()` samples `heapUsed` every `memorySampleIntervalMs` (default 15s) and fits a least-squares line over the recent window. A **sustained** positive slope (MB/min) above `heapGrowthMbPerMin` triggers `memory-leak` — this ignores the sawtooth of normal GC, which single-threshold checks get wrong. Set `heapUsedMb` to also alert on an absolute heap ceiling.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
| Option | Default | Description |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `serviceName` | `"node-observe"` | Label in snapshots/alerts. |
|
|
103
|
+
| `slackWebhook` | — | Slack incoming-webhook URL. |
|
|
104
|
+
| `thresholds.requestMs` | `1000` | Slow-request alert threshold. |
|
|
105
|
+
| `thresholds.mongoMs` | `300` | Slow-Mongo alert threshold. |
|
|
106
|
+
| `thresholds.redisMs` | `100` | Slow-Redis alert threshold. |
|
|
107
|
+
| `thresholds.heapGrowthMbPerMin` | `50` | Leak alert threshold. |
|
|
108
|
+
| `thresholds.heapUsedMb` | `0` (off) | Absolute heap alert. |
|
|
109
|
+
| `sampleSize` | `1024` | Ring-buffer size per metric (percentile window). |
|
|
110
|
+
| `alertCooldownMs` | `60000` | Min gap between repeats of the same alert. |
|
|
111
|
+
| `memorySampleIntervalMs` | `15000` | Memory sampling cadence. |
|
|
112
|
+
| `normalizePaths` | `true` | Collapse `/users/123` → `/users/:id`. |
|
|
113
|
+
| `onAlert` | — | Custom alert sink. |
|
|
114
|
+
| `logger` | `console` | Internal error logger. |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
| Export | Description |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `new Insights(options)` | The central handle. |
|
|
123
|
+
| `insights.express()` | Express/Connect middleware (request latency). |
|
|
124
|
+
| `insights.instrumentMongoose(mongoose)` | Global plugin timing all queries. |
|
|
125
|
+
| `insights.instrumentRedis(client)` | Wrap an ioredis client's commands. |
|
|
126
|
+
| `insights.startMemoryMonitor()` | Start leak detection (returns a stop fn). |
|
|
127
|
+
| `insights.snapshot()` | Current `InsightsSnapshot` object. |
|
|
128
|
+
| `insights.handler()` | Express handler serving the snapshot as JSON. |
|
|
129
|
+
| `insights.recordRequest/recordMongo/recordRedis(...)` | Manual recording. |
|
|
130
|
+
| `Histogram`, `MetricsRegistry`, `MemoryMonitor` | Building blocks, exported for custom use. |
|
|
131
|
+
| `normalizePath`, `formatSlackMessage`, `postToSlack` | Utilities. |
|
|
132
|
+
|
|
133
|
+
## Notes
|
|
134
|
+
|
|
135
|
+
- The Express middleware records on the response `finish`/`close` event and prefers the matched route pattern (`req.route.path`) to keep cardinality low.
|
|
136
|
+
- `instrumentMongoose` must run **before** your models are compiled so the global plugin applies to every schema.
|
|
137
|
+
- `instrumentRedis` is idempotent — calling it twice on the same client is a no-op.
|
|
138
|
+
- Observability never throws into your app: Slack failures and `onAlert` errors are logged, not propagated.
|
|
139
|
+
- All durations are milliseconds; memory values are MB.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/histogram.ts
|
|
4
|
+
var Histogram = class {
|
|
5
|
+
buffer = [];
|
|
6
|
+
head = 0;
|
|
7
|
+
cap;
|
|
8
|
+
count = 0;
|
|
9
|
+
errors = 0;
|
|
10
|
+
sum = 0;
|
|
11
|
+
minV = Infinity;
|
|
12
|
+
maxV = -Infinity;
|
|
13
|
+
constructor(cap = 1024) {
|
|
14
|
+
this.cap = Math.max(1, cap);
|
|
15
|
+
}
|
|
16
|
+
/** Record one duration (ms). Mark `isError` to count it toward the error tally. */
|
|
17
|
+
record(value, isError = false) {
|
|
18
|
+
if (!Number.isFinite(value) || value < 0) return;
|
|
19
|
+
this.count++;
|
|
20
|
+
this.sum += value;
|
|
21
|
+
if (isError) this.errors++;
|
|
22
|
+
if (value < this.minV) this.minV = value;
|
|
23
|
+
if (value > this.maxV) this.maxV = value;
|
|
24
|
+
if (this.buffer.length < this.cap) {
|
|
25
|
+
this.buffer.push(value);
|
|
26
|
+
} else {
|
|
27
|
+
this.buffer[this.head] = value;
|
|
28
|
+
this.head = (this.head + 1) % this.cap;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Linear-interpolation percentile over the retained window. `p` in [0,100]. */
|
|
32
|
+
percentile(p) {
|
|
33
|
+
const n = this.buffer.length;
|
|
34
|
+
if (n === 0) return 0;
|
|
35
|
+
const sorted = [...this.buffer].sort((a, b) => a - b);
|
|
36
|
+
if (n === 1) return sorted[0];
|
|
37
|
+
const rank = p / 100 * (n - 1);
|
|
38
|
+
const lo = Math.floor(rank);
|
|
39
|
+
const hi = Math.ceil(rank);
|
|
40
|
+
const frac = rank - lo;
|
|
41
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
42
|
+
}
|
|
43
|
+
snapshot() {
|
|
44
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
45
|
+
return {
|
|
46
|
+
count: this.count,
|
|
47
|
+
errors: this.errors,
|
|
48
|
+
mean: this.count ? round(this.sum / this.count) : 0,
|
|
49
|
+
min: this.count ? round(this.minV) : 0,
|
|
50
|
+
max: this.count ? round(this.maxV) : 0,
|
|
51
|
+
p50: round(this.percentile(50)),
|
|
52
|
+
p95: round(this.percentile(95)),
|
|
53
|
+
p99: round(this.percentile(99))
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var MetricsRegistry = class {
|
|
58
|
+
constructor(cap = 1024) {
|
|
59
|
+
this.cap = cap;
|
|
60
|
+
}
|
|
61
|
+
cap;
|
|
62
|
+
histograms = /* @__PURE__ */ new Map();
|
|
63
|
+
counters = /* @__PURE__ */ new Map();
|
|
64
|
+
record(name, value, isError = false) {
|
|
65
|
+
let h = this.histograms.get(name);
|
|
66
|
+
if (!h) {
|
|
67
|
+
h = new Histogram(this.cap);
|
|
68
|
+
this.histograms.set(name, h);
|
|
69
|
+
}
|
|
70
|
+
h.record(value, isError);
|
|
71
|
+
}
|
|
72
|
+
increment(name, by = 1) {
|
|
73
|
+
this.counters.set(name, (this.counters.get(name) ?? 0) + by);
|
|
74
|
+
}
|
|
75
|
+
snapshot() {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [name, h] of this.histograms) out[name] = h.snapshot();
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
counterSnapshot() {
|
|
81
|
+
return Object.fromEntries(this.counters);
|
|
82
|
+
}
|
|
83
|
+
reset() {
|
|
84
|
+
this.histograms.clear();
|
|
85
|
+
this.counters.clear();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/memory.ts
|
|
90
|
+
var MemoryMonitor = class {
|
|
91
|
+
samples = [];
|
|
92
|
+
window;
|
|
93
|
+
constructor(window = 30) {
|
|
94
|
+
this.window = Math.max(2, window);
|
|
95
|
+
}
|
|
96
|
+
/** Add the current memory reading. `now`/`mem` are injectable for testing. */
|
|
97
|
+
sample(now, mem) {
|
|
98
|
+
this.samples.push({ t: now, heapUsed: mem.heapUsed });
|
|
99
|
+
if (this.samples.length > this.window) this.samples.shift();
|
|
100
|
+
}
|
|
101
|
+
/** Heap growth rate in MB per minute over the retained window. */
|
|
102
|
+
growthMbPerMin() {
|
|
103
|
+
if (this.samples.length < 2) return 0;
|
|
104
|
+
const n = this.samples.length;
|
|
105
|
+
const t0 = this.samples[0].t;
|
|
106
|
+
let sx = 0;
|
|
107
|
+
let sy = 0;
|
|
108
|
+
let sxx = 0;
|
|
109
|
+
let sxy = 0;
|
|
110
|
+
for (const s of this.samples) {
|
|
111
|
+
const x = (s.t - t0) / 6e4;
|
|
112
|
+
const y = s.heapUsed / (1024 * 1024);
|
|
113
|
+
sx += x;
|
|
114
|
+
sy += y;
|
|
115
|
+
sxx += x * x;
|
|
116
|
+
sxy += x * y;
|
|
117
|
+
}
|
|
118
|
+
const denom = n * sxx - sx * sx;
|
|
119
|
+
if (denom === 0) return 0;
|
|
120
|
+
const slope = (n * sxy - sx * sy) / denom;
|
|
121
|
+
return Math.round(slope * 100) / 100;
|
|
122
|
+
}
|
|
123
|
+
snapshot(mem) {
|
|
124
|
+
const mb = (b) => Math.round(b / (1024 * 1024) * 100) / 100;
|
|
125
|
+
return {
|
|
126
|
+
heapUsedMb: mb(mem.heapUsed),
|
|
127
|
+
heapTotalMb: mb(mem.heapTotal),
|
|
128
|
+
rssMb: mb(mem.rss),
|
|
129
|
+
externalMb: mb(mem.external),
|
|
130
|
+
growthMbPerMin: this.growthMbPerMin()
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Number of samples currently retained. */
|
|
134
|
+
get size() {
|
|
135
|
+
return this.samples.length;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// src/slack.ts
|
|
140
|
+
var EMOJI = {
|
|
141
|
+
"slow-request": "\u{1F422}",
|
|
142
|
+
"slow-mongo": "\u{1F343}",
|
|
143
|
+
"slow-redis": "\u{1F9F1}",
|
|
144
|
+
"memory-leak": "\u{1F4C8}",
|
|
145
|
+
"memory-high": "\u{1F6A8}"
|
|
146
|
+
};
|
|
147
|
+
function formatSlackMessage(alert, service) {
|
|
148
|
+
const emoji = EMOJI[alert.type] ?? "\u26A0\uFE0F";
|
|
149
|
+
return `${emoji} *[${service}] ${alert.type}*
|
|
150
|
+
${alert.message}
|
|
151
|
+
\u2022 value: \`${alert.value}\` \u2022 threshold: \`${alert.threshold}\` \u2022 at: ${alert.at}`;
|
|
152
|
+
}
|
|
153
|
+
async function postToSlack(webhook, alert, service, logger) {
|
|
154
|
+
if (typeof fetch !== "function") {
|
|
155
|
+
logger?.warn?.("node-observe: global fetch unavailable (need Node 18+); skipping Slack alert");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(webhook, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ text: formatSlackMessage(alert, service) })
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
logger?.error?.(`node-observe: Slack webhook returned ${res.status}`);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
logger?.error?.(`node-observe: Slack alert failed: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/util.ts
|
|
173
|
+
function normalizePath(path) {
|
|
174
|
+
const clean = path.split("?")[0] ?? path;
|
|
175
|
+
return clean.split("/").map((seg) => {
|
|
176
|
+
if (seg === "") return seg;
|
|
177
|
+
if (/^\d+$/.test(seg)) return ":id";
|
|
178
|
+
if (/^[0-9a-f]{24}$/i.test(seg)) return ":id";
|
|
179
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return ":id";
|
|
180
|
+
if (/^[0-9a-f]{32,}$/i.test(seg)) return ":id";
|
|
181
|
+
return seg;
|
|
182
|
+
}).join("/");
|
|
183
|
+
}
|
|
184
|
+
function elapsedMs(start) {
|
|
185
|
+
return Number(process.hrtime.bigint() - start) / 1e6;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/express.ts
|
|
189
|
+
function instrumentExpress(insights) {
|
|
190
|
+
return function nodeObserveMiddleware(req, res, next) {
|
|
191
|
+
const start = process.hrtime.bigint();
|
|
192
|
+
let done = false;
|
|
193
|
+
const finish2 = () => {
|
|
194
|
+
if (done) return;
|
|
195
|
+
done = true;
|
|
196
|
+
const ms = elapsedMs(start);
|
|
197
|
+
const method = req.method ?? "GET";
|
|
198
|
+
const route = routeOf(req);
|
|
199
|
+
insights.recordRequest(method, route, res.statusCode ?? 0, ms);
|
|
200
|
+
};
|
|
201
|
+
res.on("finish", finish2);
|
|
202
|
+
res.on("close", finish2);
|
|
203
|
+
next();
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function routeOf(req) {
|
|
207
|
+
if (req.route?.path) {
|
|
208
|
+
const base = req.baseUrl ?? "";
|
|
209
|
+
return base + req.route.path || req.route.path;
|
|
210
|
+
}
|
|
211
|
+
return req.path ?? (req.originalUrl ?? req.url ?? "unknown").split("?")[0] ?? "unknown";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/mongo.ts
|
|
215
|
+
var START = /* @__PURE__ */ Symbol("nodeObserveStart");
|
|
216
|
+
var QUERY_HOOK = /^(find|findOne|findOneAnd|count|countDocuments|estimatedDocumentCount|update|updateOne|updateMany|delete|deleteOne|deleteMany|replaceOne)/;
|
|
217
|
+
function instrumentMongoose(mongoose, insights) {
|
|
218
|
+
const m = mongoose;
|
|
219
|
+
if (!m || typeof m.plugin !== "function") {
|
|
220
|
+
insights.logger.warn?.("node-observe: instrumentMongoose called with a non-mongoose value; skipping");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
m.plugin((schema) => {
|
|
224
|
+
schema.pre(QUERY_HOOK, function() {
|
|
225
|
+
this[START] = process.hrtime.bigint();
|
|
226
|
+
});
|
|
227
|
+
schema.post(QUERY_HOOK, function() {
|
|
228
|
+
finish(insights, modelName(this), this.op ?? "query", this[START]);
|
|
229
|
+
});
|
|
230
|
+
schema.pre("aggregate", function() {
|
|
231
|
+
this[START] = process.hrtime.bigint();
|
|
232
|
+
});
|
|
233
|
+
schema.post("aggregate", function() {
|
|
234
|
+
finish(insights, aggregateModelName(this), "aggregate", this[START]);
|
|
235
|
+
});
|
|
236
|
+
schema.pre("save", function() {
|
|
237
|
+
this[START] = process.hrtime.bigint();
|
|
238
|
+
});
|
|
239
|
+
schema.post("save", function() {
|
|
240
|
+
finish(insights, this?.constructor?.modelName ?? "Document", "save", this[START]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function finish(insights, model, op, start) {
|
|
245
|
+
if (typeof start !== "bigint") return;
|
|
246
|
+
insights.recordMongo(`${model}.${op}`, elapsedMs(start));
|
|
247
|
+
}
|
|
248
|
+
function modelName(query) {
|
|
249
|
+
return query?.model?.modelName ?? query?.mongooseCollection?.name ?? "Model";
|
|
250
|
+
}
|
|
251
|
+
function aggregateModelName(agg) {
|
|
252
|
+
return agg?._model?.modelName ?? agg?.model?.()?.modelName ?? "Model";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/redis.ts
|
|
256
|
+
var PATCHED = /* @__PURE__ */ Symbol.for("nodeObserve.redisPatched");
|
|
257
|
+
function instrumentRedis(client, insights) {
|
|
258
|
+
const c = client;
|
|
259
|
+
if (!c || typeof c.sendCommand !== "function") {
|
|
260
|
+
insights.logger.warn?.("node-observe: instrumentRedis called with a non-ioredis value; skipping");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (c[PATCHED]) return;
|
|
264
|
+
c[PATCHED] = true;
|
|
265
|
+
const original = c.sendCommand.bind(c);
|
|
266
|
+
c.sendCommand = function patchedSendCommand(command, ...rest) {
|
|
267
|
+
const start = process.hrtime.bigint();
|
|
268
|
+
const name = (command?.name ?? "unknown").toLowerCase();
|
|
269
|
+
const result = original(command, ...rest);
|
|
270
|
+
const promise = command?.promise ?? result;
|
|
271
|
+
if (promise && typeof promise.then === "function") {
|
|
272
|
+
promise.then(
|
|
273
|
+
() => insights.recordRedis(name, elapsedMs(start), false),
|
|
274
|
+
() => insights.recordRedis(name, elapsedMs(start), true)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/insights.ts
|
|
282
|
+
var consoleLogger = {
|
|
283
|
+
info: (m) => console.log(m),
|
|
284
|
+
error: (m) => console.error(m),
|
|
285
|
+
warn: (m) => console.warn(m),
|
|
286
|
+
debug: (m) => console.debug(m)
|
|
287
|
+
};
|
|
288
|
+
var DEFAULT_THRESHOLDS = {
|
|
289
|
+
requestMs: 1e3,
|
|
290
|
+
mongoMs: 300,
|
|
291
|
+
redisMs: 100,
|
|
292
|
+
heapGrowthMbPerMin: 50,
|
|
293
|
+
heapUsedMb: 0
|
|
294
|
+
// disabled by default
|
|
295
|
+
};
|
|
296
|
+
var Insights = class {
|
|
297
|
+
http;
|
|
298
|
+
mongo;
|
|
299
|
+
redis;
|
|
300
|
+
counters;
|
|
301
|
+
memory;
|
|
302
|
+
serviceName;
|
|
303
|
+
thresholds;
|
|
304
|
+
logger;
|
|
305
|
+
opts;
|
|
306
|
+
cooldownMs;
|
|
307
|
+
lastAlertAt = /* @__PURE__ */ new Map();
|
|
308
|
+
memoryTimer;
|
|
309
|
+
startedAt = Date.now();
|
|
310
|
+
constructor(options = {}) {
|
|
311
|
+
this.opts = options;
|
|
312
|
+
this.serviceName = options.serviceName ?? "node-observe";
|
|
313
|
+
this.thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds ?? {} };
|
|
314
|
+
this.logger = options.logger ?? consoleLogger;
|
|
315
|
+
this.cooldownMs = options.alertCooldownMs ?? 6e4;
|
|
316
|
+
this.memory = new MemoryMonitor();
|
|
317
|
+
const cap = options.sampleSize ?? 1024;
|
|
318
|
+
this.http = new MetricsRegistry(cap);
|
|
319
|
+
this.mongo = new MetricsRegistry(cap);
|
|
320
|
+
this.redis = new MetricsRegistry(cap);
|
|
321
|
+
this.counters = new MetricsRegistry(cap);
|
|
322
|
+
}
|
|
323
|
+
// ---- recording (used by the instrumentations) -----------------------------
|
|
324
|
+
/** Record an HTTP request. `route` is normalized when `normalizePaths` is on. */
|
|
325
|
+
recordRequest(method, route, statusCode, ms) {
|
|
326
|
+
const path = this.opts.normalizePaths === false ? route : normalizePath(route);
|
|
327
|
+
const isError = statusCode >= 500;
|
|
328
|
+
const key = `${method.toUpperCase()} ${path}`;
|
|
329
|
+
this.http.record(key, ms, isError);
|
|
330
|
+
this.counters.increment(`http.status.${Math.floor(statusCode / 100)}xx`);
|
|
331
|
+
if (ms > this.thresholds.requestMs) {
|
|
332
|
+
this.raise("slow-request", key, `${key} took ${ms.toFixed(0)}ms`, ms, this.thresholds.requestMs);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Record a Mongo operation, e.g. `User.find`. */
|
|
336
|
+
recordMongo(operation, ms, isError = false) {
|
|
337
|
+
this.mongo.record(operation, ms, isError);
|
|
338
|
+
if (ms > this.thresholds.mongoMs) {
|
|
339
|
+
this.raise("slow-mongo", operation, `Mongo ${operation} took ${ms.toFixed(0)}ms`, ms, this.thresholds.mongoMs);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Record a Redis command, e.g. `get`. */
|
|
343
|
+
recordRedis(command, ms, isError = false) {
|
|
344
|
+
this.redis.record(command, ms, isError);
|
|
345
|
+
if (ms > this.thresholds.redisMs) {
|
|
346
|
+
this.raise("slow-redis", command, `Redis ${command} took ${ms.toFixed(0)}ms`, ms, this.thresholds.redisMs);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ---- instrumentation attach points ----------------------------------------
|
|
350
|
+
/** Express/Connect middleware that times every request. */
|
|
351
|
+
express() {
|
|
352
|
+
return instrumentExpress(this);
|
|
353
|
+
}
|
|
354
|
+
/** Instrument a Mongoose instance so every query/aggregate/save is timed. */
|
|
355
|
+
instrumentMongoose(mongoose) {
|
|
356
|
+
instrumentMongoose(mongoose, this);
|
|
357
|
+
}
|
|
358
|
+
/** Instrument an ioredis client so every command is timed. */
|
|
359
|
+
instrumentRedis(client) {
|
|
360
|
+
instrumentRedis(client, this);
|
|
361
|
+
}
|
|
362
|
+
/** Begin periodic memory sampling + leak detection. Returns a stop function. */
|
|
363
|
+
startMemoryMonitor() {
|
|
364
|
+
if (this.memoryTimer) return () => this.stopMemoryMonitor();
|
|
365
|
+
const interval = this.opts.memorySampleIntervalMs ?? 15e3;
|
|
366
|
+
this.memoryTimer = setInterval(() => this.checkMemory(), interval);
|
|
367
|
+
this.memoryTimer.unref?.();
|
|
368
|
+
return () => this.stopMemoryMonitor();
|
|
369
|
+
}
|
|
370
|
+
stopMemoryMonitor() {
|
|
371
|
+
if (this.memoryTimer) {
|
|
372
|
+
clearInterval(this.memoryTimer);
|
|
373
|
+
this.memoryTimer = void 0;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Take one memory reading and evaluate the leak/high-heap thresholds.
|
|
378
|
+
* `now`/`mem` are injectable for testing; both default to live process values.
|
|
379
|
+
*/
|
|
380
|
+
checkMemory(now = Date.now(), mem = process.memoryUsage()) {
|
|
381
|
+
this.memory.sample(now, mem);
|
|
382
|
+
const snap = this.memory.snapshot(mem);
|
|
383
|
+
if (this.memory.size >= 5 && snap.growthMbPerMin > this.thresholds.heapGrowthMbPerMin) {
|
|
384
|
+
this.raise(
|
|
385
|
+
"memory-leak",
|
|
386
|
+
"heap",
|
|
387
|
+
`Heap growing ${snap.growthMbPerMin}MB/min (now ${snap.heapUsedMb}MB)`,
|
|
388
|
+
snap.growthMbPerMin,
|
|
389
|
+
this.thresholds.heapGrowthMbPerMin
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (this.thresholds.heapUsedMb > 0 && snap.heapUsedMb > this.thresholds.heapUsedMb) {
|
|
393
|
+
this.raise(
|
|
394
|
+
"memory-high",
|
|
395
|
+
"heap",
|
|
396
|
+
`Heap usage ${snap.heapUsedMb}MB`,
|
|
397
|
+
snap.heapUsedMb,
|
|
398
|
+
this.thresholds.heapUsedMb
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// ---- output ---------------------------------------------------------------
|
|
403
|
+
snapshot() {
|
|
404
|
+
const mem = process.memoryUsage();
|
|
405
|
+
return {
|
|
406
|
+
service: this.serviceName,
|
|
407
|
+
uptimeSec: Math.round((Date.now() - this.startedAt) / 1e3),
|
|
408
|
+
http: this.http.snapshot(),
|
|
409
|
+
mongo: this.mongo.snapshot(),
|
|
410
|
+
redis: this.redis.snapshot(),
|
|
411
|
+
counters: this.counters.counterSnapshot(),
|
|
412
|
+
memory: this.memory.snapshot(mem),
|
|
413
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** Express handler that responds with the JSON snapshot. */
|
|
417
|
+
handler() {
|
|
418
|
+
return (_req, res) => {
|
|
419
|
+
const body = this.snapshot();
|
|
420
|
+
if (typeof res.json === "function") {
|
|
421
|
+
res.json(body);
|
|
422
|
+
} else {
|
|
423
|
+
res.setHeader?.("Content-Type", "application/json");
|
|
424
|
+
res.end?.(JSON.stringify(body));
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// ---- alerting -------------------------------------------------------------
|
|
429
|
+
raise(type, key, message, value, threshold) {
|
|
430
|
+
const dedupeKey = `${type}:${key}`;
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
const last = this.lastAlertAt.get(dedupeKey) ?? 0;
|
|
433
|
+
if (now - last < this.cooldownMs) return;
|
|
434
|
+
this.lastAlertAt.set(dedupeKey, now);
|
|
435
|
+
const alert = {
|
|
436
|
+
type,
|
|
437
|
+
key,
|
|
438
|
+
message,
|
|
439
|
+
value: Math.round(value * 100) / 100,
|
|
440
|
+
threshold,
|
|
441
|
+
at: new Date(now).toISOString()
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
this.opts.onAlert?.(alert);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
this.logger.error(`node-observe: onAlert handler threw: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
if (this.opts.slackWebhook) {
|
|
449
|
+
void postToSlack(this.opts.slackWebhook, alert, this.serviceName, this.logger);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
exports.Histogram = Histogram;
|
|
455
|
+
exports.Insights = Insights;
|
|
456
|
+
exports.MemoryMonitor = MemoryMonitor;
|
|
457
|
+
exports.MetricsRegistry = MetricsRegistry;
|
|
458
|
+
exports.elapsedMs = elapsedMs;
|
|
459
|
+
exports.formatSlackMessage = formatSlackMessage;
|
|
460
|
+
exports.instrumentExpress = instrumentExpress;
|
|
461
|
+
exports.instrumentMongoose = instrumentMongoose;
|
|
462
|
+
exports.instrumentRedis = instrumentRedis;
|
|
463
|
+
exports.normalizePath = normalizePath;
|
|
464
|
+
exports.postToSlack = postToSlack;
|
|
465
|
+
//# sourceMappingURL=index.cjs.map
|
|
466
|
+
//# sourceMappingURL=index.cjs.map
|