sentinelle-agent 1.0.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/README.md +144 -0
- package/package.json +45 -0
- package/src/core.js +320 -0
- package/src/express.js +140 -0
- package/src/index.js +95 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# sentinelle-agent
|
|
2
|
+
|
|
3
|
+
Sentinelle monitoring agent for **Node.js** apps. Supports **Express**, **Fastify**, **Koa**, **NestJS**.
|
|
4
|
+
|
|
5
|
+
Pair this with the official dashboard at [sentinelle.dev](https://sentinelle.dev) to get heartbeats, route discovery, error capture and request metrics — all visible in real time.
|
|
6
|
+
|
|
7
|
+
A Python sibling is published as [`sentinelle-agent` on PyPI](https://pypi.org/project/sentinelle-agent/) with identical wire format.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install sentinelle-agent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
Two ways, equivalent:
|
|
18
|
+
|
|
19
|
+
**Env vars** (recommended in production):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
SENTINELLE_API_KEY=your-project-api-key
|
|
23
|
+
SENTINELLE_SERVER_URL=https://api.sentinelle.dev
|
|
24
|
+
SENTINELLE_APP_NAME=my-node-app
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Code:**
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const sentinelle = require('sentinelle-agent');
|
|
31
|
+
|
|
32
|
+
sentinelle.init({
|
|
33
|
+
apiKey: 'your-project-api-key',
|
|
34
|
+
serverUrl: 'https://api.sentinelle.dev',
|
|
35
|
+
appName: 'my-node-app',
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Get your `apiKey` from your Sentinelle dashboard — project settings.
|
|
40
|
+
|
|
41
|
+
## Express
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const express = require('express');
|
|
45
|
+
const sentinelle = require('sentinelle-agent');
|
|
46
|
+
|
|
47
|
+
sentinelle.init({ appName: 'my-app' });
|
|
48
|
+
|
|
49
|
+
const app = express();
|
|
50
|
+
|
|
51
|
+
// BEFORE your routes
|
|
52
|
+
app.use(sentinelle.middleware());
|
|
53
|
+
|
|
54
|
+
// your routes here
|
|
55
|
+
app.get('/hello', (req, res) => res.json({ ok: true }));
|
|
56
|
+
|
|
57
|
+
// AFTER your routes
|
|
58
|
+
app.use(sentinelle.errorHandler());
|
|
59
|
+
|
|
60
|
+
// Auto-discover all routes (call after registration)
|
|
61
|
+
sentinelle.discoverRoutes(app);
|
|
62
|
+
|
|
63
|
+
app.listen(3000);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## NestJS
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// main.ts
|
|
70
|
+
import * as sentinelle from 'sentinelle-agent';
|
|
71
|
+
import { NestFactory } from '@nestjs/core';
|
|
72
|
+
import { AppModule } from './app.module';
|
|
73
|
+
|
|
74
|
+
async function bootstrap() {
|
|
75
|
+
sentinelle.init({ appName: 'my-nestjs-app' });
|
|
76
|
+
|
|
77
|
+
const app = await NestFactory.create(AppModule);
|
|
78
|
+
const expressApp = app.getHttpAdapter().getInstance();
|
|
79
|
+
expressApp.use(sentinelle.middleware());
|
|
80
|
+
expressApp.use(sentinelle.errorHandler());
|
|
81
|
+
|
|
82
|
+
await app.listen(3000);
|
|
83
|
+
sentinelle.discoverRoutes(expressApp);
|
|
84
|
+
}
|
|
85
|
+
bootstrap();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## What it does
|
|
89
|
+
|
|
90
|
+
**Per-request** (Express middleware):
|
|
91
|
+
- Times each request, captures method/path/status/responseMs/IP/UA
|
|
92
|
+
- Aggregates top routes, errors 4xx/5xx, status distribution
|
|
93
|
+
|
|
94
|
+
**Errors:**
|
|
95
|
+
- Unhandled exceptions in routes (via `errorHandler` middleware)
|
|
96
|
+
- Uncaught exceptions and unhandled rejections at process level
|
|
97
|
+
- Slow requests (> 5000 ms) reported as warnings
|
|
98
|
+
- Sensitive fields auto-redacted in bodies: `password`, `token`, `secret`, `authorization`, `cookie`, `credit_card`, `cvv`, `ssn`
|
|
99
|
+
|
|
100
|
+
**Routes:**
|
|
101
|
+
- Discovered automatically via Express internal router stack
|
|
102
|
+
- Posted once to `/api/webhooks/agent/routes`
|
|
103
|
+
|
|
104
|
+
**Heartbeat** (every 60s):
|
|
105
|
+
- App uptime, RSS / heap memory, Node version
|
|
106
|
+
- Request metrics (total, avg ms, errors 4xx/5xx, top 5 routes)
|
|
107
|
+
- Posted to `/api/webhooks/agent/heartbeat`
|
|
108
|
+
|
|
109
|
+
**Server-driven kill switch:**
|
|
110
|
+
If the backend returns `kill_switch_active: true` in a heartbeat response, the agent stops uploading new errors (but keeps sending heartbeats so the project stays observable).
|
|
111
|
+
|
|
112
|
+
## Custom errors
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
const sentinelle = require('sentinelle-agent');
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await chargeCard(user);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
sentinelle.getAgent()?.reportError({
|
|
121
|
+
type: 'PaymentFailed',
|
|
122
|
+
message: err.message,
|
|
123
|
+
fatal: false,
|
|
124
|
+
});
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Options
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
sentinelle.init({
|
|
133
|
+
apiKey: '...',
|
|
134
|
+
serverUrl: 'https://api.sentinelle.dev',
|
|
135
|
+
appName: 'my-app',
|
|
136
|
+
heartbeatInterval: 60000, // ms, default 60_000
|
|
137
|
+
captureUnhandled: true, // install process-level handlers
|
|
138
|
+
debug: false, // print [Sentinelle Agent] log lines
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sentinelle-agent",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Sentinelle monitoring agent for Node.js — error capture, route discovery, request metrics, and health reporting. Supports Express, Fastify, Koa, NestJS.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"monitoring",
|
|
8
|
+
"rasp",
|
|
9
|
+
"security",
|
|
10
|
+
"observability",
|
|
11
|
+
"error-tracking",
|
|
12
|
+
"health-check",
|
|
13
|
+
"express",
|
|
14
|
+
"fastify",
|
|
15
|
+
"koa",
|
|
16
|
+
"nestjs",
|
|
17
|
+
"middleware",
|
|
18
|
+
"sentinelle"
|
|
19
|
+
],
|
|
20
|
+
"author": "Sentinelle <hello@sentinelle.dev>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=16.0.0"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"homepage": "https://sentinelle.dev",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/sentinelle-dev/sentinelle-agent.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/sentinelle-dev/sentinelle-agent/issues"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"express": ">=4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"express": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentCore — the heart of the Sentinelle agent
|
|
3
|
+
* Handles heartbeat, error batching, request metrics, and communication with Sentinelle server
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class AgentCore {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.running = false;
|
|
10
|
+
this.heartbeatTimer = null;
|
|
11
|
+
this.flushTimer = null;
|
|
12
|
+
|
|
13
|
+
// Buffers
|
|
14
|
+
this.errors = [];
|
|
15
|
+
this.requestMetrics = {
|
|
16
|
+
total: 0,
|
|
17
|
+
byStatus: {},
|
|
18
|
+
byRoute: {},
|
|
19
|
+
avgResponseMs: 0,
|
|
20
|
+
totalResponseMs: 0,
|
|
21
|
+
errors5xx: 0,
|
|
22
|
+
errors4xx: 0,
|
|
23
|
+
};
|
|
24
|
+
this.discoveredRoutes = [];
|
|
25
|
+
|
|
26
|
+
// Server-driven kill switch state — set by heartbeat response.
|
|
27
|
+
// Quand actif, l'agent suspend l'envoi d'erreurs (qui pourraient déclencher
|
|
28
|
+
// des fix auto) mais continue le heartbeat pour rester observable.
|
|
29
|
+
this.killSwitchActive = false;
|
|
30
|
+
this.killSwitchSince = null;
|
|
31
|
+
|
|
32
|
+
// System info
|
|
33
|
+
this.systemInfo = {
|
|
34
|
+
nodeVersion: process.version,
|
|
35
|
+
platform: process.platform,
|
|
36
|
+
arch: process.arch,
|
|
37
|
+
pid: process.pid,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
log(msg) {
|
|
42
|
+
if (this.config.debug) {
|
|
43
|
+
console.log(`[Sentinelle Agent] ${msg}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Start the agent — begins heartbeat and sets up unhandled error capture
|
|
49
|
+
*/
|
|
50
|
+
start() {
|
|
51
|
+
if (this.running) return;
|
|
52
|
+
this.running = true;
|
|
53
|
+
|
|
54
|
+
this.log(`Starting agent for "${this.config.appName}" → ${this.config.serverUrl}`);
|
|
55
|
+
|
|
56
|
+
// Heartbeat
|
|
57
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.heartbeatInterval);
|
|
58
|
+
|
|
59
|
+
// Flush error buffer every 30s
|
|
60
|
+
this.flushTimer = setInterval(() => this.flushErrors(), 30000);
|
|
61
|
+
|
|
62
|
+
// Capture unhandled errors
|
|
63
|
+
if (this.config.captureUnhandled) {
|
|
64
|
+
process.on('uncaughtException', (err) => {
|
|
65
|
+
this.reportError({
|
|
66
|
+
type: 'UncaughtException',
|
|
67
|
+
message: err.message,
|
|
68
|
+
stack: err.stack,
|
|
69
|
+
fatal: true,
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
});
|
|
72
|
+
// Flush immediately for fatal errors
|
|
73
|
+
this.flushErrors();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
process.on('unhandledRejection', (reason) => {
|
|
77
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
78
|
+
this.reportError({
|
|
79
|
+
type: 'UnhandledRejection',
|
|
80
|
+
message: err.message,
|
|
81
|
+
stack: err.stack,
|
|
82
|
+
fatal: false,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Send initial heartbeat
|
|
89
|
+
setTimeout(() => this.sendHeartbeat(), 2000);
|
|
90
|
+
|
|
91
|
+
this.log('Agent started');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stop the agent
|
|
96
|
+
*/
|
|
97
|
+
async stop() {
|
|
98
|
+
this.running = false;
|
|
99
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
100
|
+
if (this.flushTimer) clearInterval(this.flushTimer);
|
|
101
|
+
|
|
102
|
+
// Flush remaining errors
|
|
103
|
+
await this.flushErrors();
|
|
104
|
+
|
|
105
|
+
this.log('Agent stopped');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Track a request (called by Express middleware)
|
|
110
|
+
*/
|
|
111
|
+
trackRequest(data) {
|
|
112
|
+
this.requestMetrics.total++;
|
|
113
|
+
this.requestMetrics.totalResponseMs += data.responseMs || 0;
|
|
114
|
+
this.requestMetrics.avgResponseMs = Math.round(
|
|
115
|
+
this.requestMetrics.totalResponseMs / this.requestMetrics.total
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// By status code
|
|
119
|
+
const status = data.statusCode || 0;
|
|
120
|
+
this.requestMetrics.byStatus[status] = (this.requestMetrics.byStatus[status] || 0) + 1;
|
|
121
|
+
|
|
122
|
+
if (status >= 500) this.requestMetrics.errors5xx++;
|
|
123
|
+
if (status >= 400 && status < 500) this.requestMetrics.errors4xx++;
|
|
124
|
+
|
|
125
|
+
// By route (top 50)
|
|
126
|
+
const routeKey = `${data.method} ${data.route || data.path}`;
|
|
127
|
+
if (!this.requestMetrics.byRoute[routeKey]) {
|
|
128
|
+
this.requestMetrics.byRoute[routeKey] = { count: 0, totalMs: 0, errors: 0 };
|
|
129
|
+
}
|
|
130
|
+
const route = this.requestMetrics.byRoute[routeKey];
|
|
131
|
+
route.count++;
|
|
132
|
+
route.totalMs += data.responseMs || 0;
|
|
133
|
+
if (status >= 400) route.errors++;
|
|
134
|
+
|
|
135
|
+
// Report slow requests as errors
|
|
136
|
+
if (data.responseMs > 5000) {
|
|
137
|
+
this.reportError({
|
|
138
|
+
type: 'SlowRequest',
|
|
139
|
+
message: `Slow request: ${data.method} ${data.path} took ${data.responseMs}ms`,
|
|
140
|
+
method: data.method,
|
|
141
|
+
path: data.path,
|
|
142
|
+
statusCode: data.statusCode,
|
|
143
|
+
responseMs: data.responseMs,
|
|
144
|
+
timestamp: data.timestamp,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Report an error (buffered, sent in batches)
|
|
151
|
+
*/
|
|
152
|
+
reportError(error) {
|
|
153
|
+
this.errors.push({
|
|
154
|
+
...error,
|
|
155
|
+
appName: this.config.appName,
|
|
156
|
+
agentVersion: '1.0.0',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
this.log(`Error captured: ${error.type} — ${error.message}`);
|
|
160
|
+
|
|
161
|
+
// Flush immediately if buffer is large or error is fatal
|
|
162
|
+
if (this.errors.length >= 10 || error.fatal) {
|
|
163
|
+
this.flushErrors();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Report discovered routes to Sentinelle
|
|
169
|
+
*/
|
|
170
|
+
async reportRoutes(routes) {
|
|
171
|
+
this.discoveredRoutes = routes;
|
|
172
|
+
this.log(`Discovered ${routes.length} routes`);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await this._send('/api/webhooks/agent/routes', {
|
|
176
|
+
appName: this.config.appName,
|
|
177
|
+
routes,
|
|
178
|
+
discoveredAt: new Date().toISOString(),
|
|
179
|
+
});
|
|
180
|
+
this.log('Routes reported to Sentinelle');
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.log(`Failed to report routes: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Send heartbeat with system info and metrics
|
|
188
|
+
*/
|
|
189
|
+
async sendHeartbeat() {
|
|
190
|
+
if (!this.running) return;
|
|
191
|
+
|
|
192
|
+
const memUsage = process.memoryUsage();
|
|
193
|
+
const uptime = process.uptime();
|
|
194
|
+
|
|
195
|
+
const payload = {
|
|
196
|
+
appName: this.config.appName,
|
|
197
|
+
status: 'alive',
|
|
198
|
+
uptime: Math.round(uptime),
|
|
199
|
+
memory: {
|
|
200
|
+
rss: Math.round(memUsage.rss / 1048576), // MB
|
|
201
|
+
heapUsed: Math.round(memUsage.heapUsed / 1048576),
|
|
202
|
+
heapTotal: Math.round(memUsage.heapTotal / 1048576),
|
|
203
|
+
},
|
|
204
|
+
system: this.systemInfo,
|
|
205
|
+
metrics: {
|
|
206
|
+
totalRequests: this.requestMetrics.total,
|
|
207
|
+
avgResponseMs: this.requestMetrics.avgResponseMs,
|
|
208
|
+
errors5xx: this.requestMetrics.errors5xx,
|
|
209
|
+
errors4xx: this.requestMetrics.errors4xx,
|
|
210
|
+
topRoutes: this._getTopRoutes(5),
|
|
211
|
+
statusDistribution: this.requestMetrics.byStatus,
|
|
212
|
+
},
|
|
213
|
+
routeCount: this.discoveredRoutes.length,
|
|
214
|
+
timestamp: new Date().toISOString(),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const response = await this._send('/api/webhooks/agent/heartbeat', payload);
|
|
219
|
+
this.log('Heartbeat sent');
|
|
220
|
+
this._applyServerDirectives(response);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
this.log(`Heartbeat failed: ${err.message}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Applique les directives renvoyées par le backend (kill switch, etc.)
|
|
228
|
+
*/
|
|
229
|
+
_applyServerDirectives(response) {
|
|
230
|
+
if (!response || typeof response !== 'object') return;
|
|
231
|
+
const wasActive = this.killSwitchActive;
|
|
232
|
+
this.killSwitchActive = !!response.kill_switch_active;
|
|
233
|
+
if (this.killSwitchActive && !wasActive) {
|
|
234
|
+
this.killSwitchSince = new Date().toISOString();
|
|
235
|
+
this.log('🛑 Kill switch activé par le serveur — pause envoi d\'erreurs');
|
|
236
|
+
} else if (!this.killSwitchActive && wasActive) {
|
|
237
|
+
this.killSwitchSince = null;
|
|
238
|
+
this.log('Kill switch relâché — reprise normale');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Flush error buffer to Sentinelle
|
|
244
|
+
*/
|
|
245
|
+
async flushErrors() {
|
|
246
|
+
if (this.errors.length === 0) return;
|
|
247
|
+
|
|
248
|
+
if (this.killSwitchActive) {
|
|
249
|
+
// On garde les erreurs en buffer (cap à 1000 pour éviter fuite mémoire)
|
|
250
|
+
// et on attend la levée du kill switch.
|
|
251
|
+
if (this.errors.length > 1000) {
|
|
252
|
+
this.errors.splice(0, this.errors.length - 1000);
|
|
253
|
+
}
|
|
254
|
+
this.log(`Kill switch actif — ${this.errors.length} erreur(s) en attente`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const batch = this.errors.splice(0, this.errors.length);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await this._send('/api/webhooks/agent/errors', {
|
|
262
|
+
appName: this.config.appName,
|
|
263
|
+
errors: batch,
|
|
264
|
+
timestamp: new Date().toISOString(),
|
|
265
|
+
});
|
|
266
|
+
this.log(`Flushed ${batch.length} errors`);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
// Put errors back if send failed
|
|
269
|
+
this.errors.unshift(...batch);
|
|
270
|
+
this.log(`Error flush failed: ${err.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get top N routes by request count
|
|
276
|
+
*/
|
|
277
|
+
_getTopRoutes(n) {
|
|
278
|
+
return Object.entries(this.requestMetrics.byRoute)
|
|
279
|
+
.map(([route, data]) => ({
|
|
280
|
+
route,
|
|
281
|
+
count: data.count,
|
|
282
|
+
avgMs: Math.round(data.totalMs / data.count),
|
|
283
|
+
errorRate: data.count > 0 ? Math.round((data.errors / data.count) * 100) : 0,
|
|
284
|
+
}))
|
|
285
|
+
.sort((a, b) => b.count - a.count)
|
|
286
|
+
.slice(0, n);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Send data to Sentinelle server
|
|
291
|
+
*/
|
|
292
|
+
async _send(path, data) {
|
|
293
|
+
const url = this.config.serverUrl + path;
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const res = await fetch(url, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: {
|
|
301
|
+
'Content-Type': 'application/json',
|
|
302
|
+
'X-Sentinelle-Key': this.config.apiKey,
|
|
303
|
+
'User-Agent': `sentinelle-agent/1.0.0 (${this.config.appName})`,
|
|
304
|
+
},
|
|
305
|
+
body: JSON.stringify(data),
|
|
306
|
+
signal: controller.signal,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
throw new Error(`HTTP ${res.status}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return await res.json();
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = { AgentCore };
|
package/src/express.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express.js integration — middleware, error handler, and route discovery
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Request tracking middleware — captures response time, status codes, and request metadata
|
|
7
|
+
* Add BEFORE your routes: app.use(sentinelle.middleware())
|
|
8
|
+
*/
|
|
9
|
+
const expressMiddleware = (agent) => {
|
|
10
|
+
return (req, res, next) => {
|
|
11
|
+
const start = process.hrtime.bigint();
|
|
12
|
+
|
|
13
|
+
// Capture response finish
|
|
14
|
+
const originalEnd = res.end;
|
|
15
|
+
res.end = function (...args) {
|
|
16
|
+
const end = process.hrtime.bigint();
|
|
17
|
+
const responseMs = Number(end - start) / 1_000_000;
|
|
18
|
+
|
|
19
|
+
// Track request
|
|
20
|
+
agent.trackRequest({
|
|
21
|
+
method: req.method,
|
|
22
|
+
path: req.originalUrl || req.url,
|
|
23
|
+
route: req.route?.path || req.originalUrl || req.url,
|
|
24
|
+
statusCode: res.statusCode,
|
|
25
|
+
responseMs: Math.round(responseMs),
|
|
26
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
27
|
+
userAgent: req.headers['user-agent'],
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
originalEnd.apply(this, args);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
next();
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Error handler middleware — captures unhandled errors in routes
|
|
40
|
+
* Add AFTER your routes: app.use(sentinelle.errorHandler())
|
|
41
|
+
*/
|
|
42
|
+
const expressErrorHandler = (agent) => {
|
|
43
|
+
return (err, req, res, next) => {
|
|
44
|
+
// Report the error to Sentinelle
|
|
45
|
+
agent.reportError({
|
|
46
|
+
type: err.name || 'Error',
|
|
47
|
+
message: err.message,
|
|
48
|
+
stack: err.stack,
|
|
49
|
+
method: req.method,
|
|
50
|
+
path: req.originalUrl || req.url,
|
|
51
|
+
route: req.route?.path,
|
|
52
|
+
statusCode: err.status || err.statusCode || 500,
|
|
53
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
54
|
+
userAgent: req.headers['user-agent'],
|
|
55
|
+
body: req.method !== 'GET' ? sanitizeBody(req.body) : undefined,
|
|
56
|
+
query: Object.keys(req.query || {}).length > 0 ? req.query : undefined,
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Don't swallow the error — pass it to the next handler
|
|
61
|
+
next(err);
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Sanitize request body — remove sensitive fields
|
|
67
|
+
*/
|
|
68
|
+
const sanitizeBody = (body) => {
|
|
69
|
+
if (!body || typeof body !== 'object') return undefined;
|
|
70
|
+
|
|
71
|
+
const sensitiveKeys = ['password', 'token', 'secret', 'authorization', 'cookie', 'credit_card', 'card_number', 'cvv', 'ssn'];
|
|
72
|
+
const sanitized = { ...body };
|
|
73
|
+
|
|
74
|
+
for (const key of Object.keys(sanitized)) {
|
|
75
|
+
if (sensitiveKeys.some(s => key.toLowerCase().includes(s))) {
|
|
76
|
+
sanitized[key] = '[REDACTED]';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return sanitized;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Discover all registered Express routes
|
|
85
|
+
* Call after all routes are registered: sentinelle.discoverRoutes(app)
|
|
86
|
+
*/
|
|
87
|
+
const discoverExpressRoutes = (app) => {
|
|
88
|
+
const routes = [];
|
|
89
|
+
|
|
90
|
+
const extractRoutes = (stack, prefix = '') => {
|
|
91
|
+
if (!stack) return;
|
|
92
|
+
|
|
93
|
+
for (const layer of stack) {
|
|
94
|
+
if (layer.route) {
|
|
95
|
+
// Direct route
|
|
96
|
+
const methods = Object.keys(layer.route.methods)
|
|
97
|
+
.filter(m => layer.route.methods[m])
|
|
98
|
+
.map(m => m.toUpperCase());
|
|
99
|
+
|
|
100
|
+
for (const method of methods) {
|
|
101
|
+
routes.push({
|
|
102
|
+
method,
|
|
103
|
+
path: prefix + layer.route.path,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} else if (layer.name === 'router' && layer.handle?.stack) {
|
|
107
|
+
// Nested router
|
|
108
|
+
const routerPrefix = layer.regexp
|
|
109
|
+
? extractPrefix(layer.regexp)
|
|
110
|
+
: '';
|
|
111
|
+
extractRoutes(layer.handle.stack, prefix + routerPrefix);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Try to access Express internal router stack
|
|
117
|
+
if (app._router?.stack) {
|
|
118
|
+
extractRoutes(app._router.stack);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return routes;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract prefix path from Express regex
|
|
126
|
+
*/
|
|
127
|
+
const extractPrefix = (regexp) => {
|
|
128
|
+
if (!regexp) return '';
|
|
129
|
+
const str = regexp.toString();
|
|
130
|
+
|
|
131
|
+
// Match patterns like /^\/api\/?(?=\/|$)/i
|
|
132
|
+
const match = str.match(/^\/\^(\\\/[^?*+{(|]+)/);
|
|
133
|
+
if (match) {
|
|
134
|
+
return match[1].replace(/\\\//g, '/');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return '';
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
module.exports = { expressMiddleware, expressErrorHandler, discoverExpressRoutes };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { AgentCore } = require('./core');
|
|
2
|
+
const { expressMiddleware, expressErrorHandler, discoverExpressRoutes } = require('./express');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sentinelle Agent — install in your Node.js app for automatic monitoring
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const devguard = require('sentinelle-agent');
|
|
9
|
+
*
|
|
10
|
+
* // Initialize
|
|
11
|
+
* sentinelle.init({
|
|
12
|
+
* apiKey: 'your-project-api-key',
|
|
13
|
+
* serverUrl: 'http://localhost:4000', // Sentinelle server
|
|
14
|
+
* appName: 'my-app',
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Express middleware (add BEFORE routes)
|
|
18
|
+
* app.use(sentinelle.middleware());
|
|
19
|
+
*
|
|
20
|
+
* // Express error handler (add AFTER routes)
|
|
21
|
+
* app.use(sentinelle.errorHandler());
|
|
22
|
+
*
|
|
23
|
+
* // Auto-discover routes (call after all routes are registered)
|
|
24
|
+
* sentinelle.discoverRoutes(app);
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
let agent = null;
|
|
28
|
+
|
|
29
|
+
const init = (options = {}) => {
|
|
30
|
+
if (agent) {
|
|
31
|
+
console.warn('[Sentinelle] Agent already initialized');
|
|
32
|
+
return agent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const config = {
|
|
36
|
+
apiKey: options.apiKey || process.env.SENTINELLE_API_KEY,
|
|
37
|
+
serverUrl: (options.serverUrl || process.env.SENTINELLE_SERVER_URL || 'http://localhost:4000').replace(/\/$/, ''),
|
|
38
|
+
appName: options.appName || process.env.SENTINELLE_APP_NAME || 'unknown',
|
|
39
|
+
heartbeatInterval: options.heartbeatInterval || 60000, // 1 min
|
|
40
|
+
captureUnhandled: options.captureUnhandled !== false,
|
|
41
|
+
debug: options.debug || false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (!config.apiKey) {
|
|
45
|
+
console.error('[Sentinelle] API key required. Set apiKey option or SENTINELLE_API_KEY env var.');
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
agent = new AgentCore(config);
|
|
50
|
+
agent.start();
|
|
51
|
+
|
|
52
|
+
return agent;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const middleware = () => {
|
|
56
|
+
return (req, res, next) => {
|
|
57
|
+
if (!agent) return next();
|
|
58
|
+
return expressMiddleware(agent)(req, res, next);
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const errorHandler = () => {
|
|
63
|
+
return (err, req, res, next) => {
|
|
64
|
+
if (!agent) return next(err);
|
|
65
|
+
return expressErrorHandler(agent)(err, req, res, next);
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const discoverRoutes = (app) => {
|
|
70
|
+
if (!agent) {
|
|
71
|
+
console.warn('[Sentinelle] Agent not initialized. Call sentinelle.init() first.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const routes = discoverExpressRoutes(app);
|
|
75
|
+
agent.reportRoutes(routes);
|
|
76
|
+
return routes;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const getAgent = () => agent;
|
|
80
|
+
|
|
81
|
+
const shutdown = async () => {
|
|
82
|
+
if (agent) {
|
|
83
|
+
await agent.stop();
|
|
84
|
+
agent = null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
init,
|
|
90
|
+
middleware,
|
|
91
|
+
errorHandler,
|
|
92
|
+
discoverRoutes,
|
|
93
|
+
getAgent,
|
|
94
|
+
shutdown,
|
|
95
|
+
};
|