@overcastsre/node 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 +47 -0
- package/index.js +784 -0
- package/package.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @overcast/node
|
|
2
|
+
|
|
3
|
+
Overcast SRE monitoring SDK for Node.js. One install, one line — captures everything.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @overcast/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
require('@overcast/node').init({ apiKey: 'oc_...', serviceName: 'my-api' });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's it. The SDK automatically captures:
|
|
18
|
+
|
|
19
|
+
- All `console.log/info/debug/warn/error` output
|
|
20
|
+
- Uncaught exceptions and unhandled promise rejections
|
|
21
|
+
- All outgoing HTTP/HTTPS requests (status, latency, request/response bodies)
|
|
22
|
+
- Process warnings and deprecations
|
|
23
|
+
- Memory usage, event-loop lag, handle leaks
|
|
24
|
+
- Stack traces with full context
|
|
25
|
+
|
|
26
|
+
## Express Middleware (optional)
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const overcast = require('@overcast/node');
|
|
30
|
+
overcast.init({ apiKey: 'oc_...' });
|
|
31
|
+
|
|
32
|
+
const app = express();
|
|
33
|
+
app.use(overcast.middleware()); // Captures all incoming requests
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
| Option | Default | Description |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `apiKey` | required | Your Overcast API key |
|
|
41
|
+
| `serviceName` | `'node-app'` | Service name in the dashboard |
|
|
42
|
+
| `environment` | `NODE_ENV` | `'production'`, `'staging'`, etc. |
|
|
43
|
+
| `encryptPayload` | `false` | AES-256-GCM encrypt payloads |
|
|
44
|
+
| `encryptionKey` | `''` | 32-byte hex key for encryption |
|
|
45
|
+
| `sanitize` | `true` | Redact PII (passwords, tokens, etc.) |
|
|
46
|
+
| `batchSize` | `50` | Logs per batch |
|
|
47
|
+
| `flushInterval` | `5000` | Flush interval in ms |
|
package/index.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @overcast/node — Overcast SRE Monitoring SDK for Node.js
|
|
3
|
+
*
|
|
4
|
+
* Captures EVERYTHING from your Node.js application:
|
|
5
|
+
* - All console output (log, info, debug, warn, error)
|
|
6
|
+
* - Uncaught exceptions & unhandled promise rejections
|
|
7
|
+
* - Process warnings & deprecations
|
|
8
|
+
* - All outgoing HTTP/HTTPS requests (status, latency, body)
|
|
9
|
+
* - Express / Fastify / Koa / Hono middleware (incoming requests)
|
|
10
|
+
* - Memory usage, event-loop lag, CPU spikes
|
|
11
|
+
* - Stack traces with source context
|
|
12
|
+
*
|
|
13
|
+
* Usage — literally two lines:
|
|
14
|
+
* const overcast = require('@overcast/node');
|
|
15
|
+
* overcast.init({ apiKey: 'oc_...', serviceName: 'my-api' });
|
|
16
|
+
*
|
|
17
|
+
* That's it. Everything is captured automatically.
|
|
18
|
+
*
|
|
19
|
+
* @module @overcast/node
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
// CONFIGURATION & STATE
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
let _config = {
|
|
29
|
+
apiKey: '',
|
|
30
|
+
serviceName: 'node-app',
|
|
31
|
+
environment: process.env.NODE_ENV || 'production',
|
|
32
|
+
baseUrl: 'https://platform.overcastsre.com',
|
|
33
|
+
version: '',
|
|
34
|
+
|
|
35
|
+
// What to capture
|
|
36
|
+
captureConsole: true, // All console.log/info/debug/warn/error
|
|
37
|
+
captureExceptions: true, // Uncaught exceptions
|
|
38
|
+
captureRejections: true, // Unhandled promise rejections
|
|
39
|
+
captureHttp: true, // Outgoing HTTP/HTTPS calls
|
|
40
|
+
capturePerformance: true, // Memory, event-loop lag
|
|
41
|
+
captureProcessWarnings: true, // Process warnings & deprecations
|
|
42
|
+
|
|
43
|
+
// Thresholds
|
|
44
|
+
slowRequestThreshold: 5000, // ms — flag outgoing calls slower than this
|
|
45
|
+
memoryWarningPercent: 85, // % — flag when heap usage exceeds this
|
|
46
|
+
eventLoopLagThreshold: 100, // ms — flag when event loop lag exceeds this
|
|
47
|
+
|
|
48
|
+
// Batching
|
|
49
|
+
batchSize: 50, // Send after N logs
|
|
50
|
+
flushInterval: 5000, // Or every N ms
|
|
51
|
+
maxBufferSize: 1000, // Drop oldest logs if buffer exceeds this
|
|
52
|
+
|
|
53
|
+
// Sampling (1.0 = 100%)
|
|
54
|
+
logSamplingRate: 1.0,
|
|
55
|
+
httpSamplingRate: 1.0,
|
|
56
|
+
|
|
57
|
+
// Security
|
|
58
|
+
encryptPayload: false, // AES-256-GCM encrypt payloads before sending
|
|
59
|
+
encryptionKey: '', // 32-byte hex key for AES-256-GCM
|
|
60
|
+
sanitize: true, // Redact PII (passwords, tokens, emails, etc.)
|
|
61
|
+
|
|
62
|
+
// Debug
|
|
63
|
+
debug: false,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let _initialized = false;
|
|
67
|
+
let _sending = false;
|
|
68
|
+
let _buffer = [];
|
|
69
|
+
let _flushTimer = null;
|
|
70
|
+
let _perfTimer = null;
|
|
71
|
+
let _originals = {};
|
|
72
|
+
|
|
73
|
+
const INGEST_ENDPOINT = '/api/v1/ingest/logs';
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// PUBLIC API
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Initialize Overcast monitoring. Call once at application startup.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} options
|
|
83
|
+
* @param {string} options.apiKey - Your Overcast API key (required)
|
|
84
|
+
* @param {string} options.serviceName - Name for this service in the dashboard
|
|
85
|
+
* @param {string} [options.environment] - 'production', 'staging', etc.
|
|
86
|
+
* @param {string} [options.baseUrl] - Overcast platform URL
|
|
87
|
+
* @param {boolean} [options.encryptPayload] - AES-256-GCM encrypt log payloads
|
|
88
|
+
* @param {string} [options.encryptionKey] - 32-byte hex key for encryption
|
|
89
|
+
*/
|
|
90
|
+
function init(options = {}) {
|
|
91
|
+
if (_initialized) {
|
|
92
|
+
_debugLog('Already initialized, skipping.');
|
|
93
|
+
return module.exports;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!options.apiKey) {
|
|
97
|
+
console.error('[Overcast] apiKey is required. Get yours at https://overcastsre.com/settings');
|
|
98
|
+
return module.exports;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Object.assign(_config, options);
|
|
102
|
+
_initialized = true;
|
|
103
|
+
|
|
104
|
+
// Install all interceptors
|
|
105
|
+
if (_config.captureConsole) _installConsoleCapture();
|
|
106
|
+
if (_config.captureExceptions) _installExceptionCapture();
|
|
107
|
+
if (_config.captureRejections) _installRejectionCapture();
|
|
108
|
+
if (_config.captureProcessWarnings) _installWarningCapture();
|
|
109
|
+
if (_config.captureHttp) _installHttpCapture();
|
|
110
|
+
if (_config.capturePerformance) _installPerformanceCapture();
|
|
111
|
+
|
|
112
|
+
// Flush on exit
|
|
113
|
+
_installShutdownHandlers();
|
|
114
|
+
|
|
115
|
+
// Start flush timer
|
|
116
|
+
_flushTimer = setInterval(_flush, _config.flushInterval);
|
|
117
|
+
if (_flushTimer.unref) _flushTimer.unref(); // Don't keep process alive
|
|
118
|
+
|
|
119
|
+
_debugLog(`Initialized for service "${_config.serviceName}" in ${_config.environment}`);
|
|
120
|
+
|
|
121
|
+
// Send init heartbeat
|
|
122
|
+
_enqueue('INFO', 'Overcast SDK initialized', {
|
|
123
|
+
type: 'sdk_init',
|
|
124
|
+
sdkVersion: '1.0.0',
|
|
125
|
+
nodeVersion: process.version,
|
|
126
|
+
platform: process.platform,
|
|
127
|
+
arch: process.arch,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return module.exports;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Manually log at any level.
|
|
135
|
+
*/
|
|
136
|
+
function error(message, metadata) { _enqueue('ERROR', message, { ...metadata, type: 'manual' }); }
|
|
137
|
+
function warn(message, metadata) { _enqueue('WARNING', message, { ...metadata, type: 'manual' }); }
|
|
138
|
+
function info(message, metadata) { _enqueue('INFO', message, { ...metadata, type: 'manual' }); }
|
|
139
|
+
function debug(message, metadata) { _enqueue('DEBUG', message, { ...metadata, type: 'manual' }); }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Capture an exception manually.
|
|
143
|
+
*/
|
|
144
|
+
function captureException(err, metadata = {}) {
|
|
145
|
+
if (!(err instanceof Error)) err = new Error(String(err));
|
|
146
|
+
_enqueue('ERROR', err.message, {
|
|
147
|
+
...metadata,
|
|
148
|
+
type: 'captured_exception',
|
|
149
|
+
error: { name: err.name, message: err.message, stack: err.stack },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Express/Connect middleware — captures all incoming requests.
|
|
155
|
+
*
|
|
156
|
+
* Usage:
|
|
157
|
+
* const app = express();
|
|
158
|
+
* app.use(overcast.middleware());
|
|
159
|
+
*/
|
|
160
|
+
function middleware() {
|
|
161
|
+
return function overcastMiddleware(req, res, next) {
|
|
162
|
+
const start = Date.now();
|
|
163
|
+
const originalEnd = res.end;
|
|
164
|
+
|
|
165
|
+
res.end = function (...args) {
|
|
166
|
+
const duration = Date.now() - start;
|
|
167
|
+
const level = res.statusCode >= 500 ? 'ERROR' : res.statusCode >= 400 ? 'WARNING' : 'INFO';
|
|
168
|
+
|
|
169
|
+
_enqueue(level, `${req.method} ${req.originalUrl || req.url} ${res.statusCode} ${duration}ms`, {
|
|
170
|
+
type: 'http_incoming',
|
|
171
|
+
method: req.method,
|
|
172
|
+
url: req.originalUrl || req.url,
|
|
173
|
+
statusCode: res.statusCode,
|
|
174
|
+
duration,
|
|
175
|
+
userAgent: req.headers['user-agent'],
|
|
176
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
177
|
+
contentLength: res.getHeader('content-length'),
|
|
178
|
+
query: req.query,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
originalEnd.apply(this, args);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
next();
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Force-flush all buffered logs immediately.
|
|
190
|
+
* Returns a Promise that resolves when the flush completes.
|
|
191
|
+
*/
|
|
192
|
+
function flush() {
|
|
193
|
+
return _flush();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Gracefully shut down the SDK. Flushes remaining logs.
|
|
198
|
+
*/
|
|
199
|
+
async function shutdown() {
|
|
200
|
+
if (_flushTimer) clearInterval(_flushTimer);
|
|
201
|
+
if (_perfTimer) clearInterval(_perfTimer);
|
|
202
|
+
await _flush();
|
|
203
|
+
_initialized = false;
|
|
204
|
+
_debugLog('Shutdown complete.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
208
|
+
// CONSOLE CAPTURE — intercepts ALL console methods
|
|
209
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
210
|
+
|
|
211
|
+
function _installConsoleCapture() {
|
|
212
|
+
const methods = ['log', 'info', 'debug', 'warn', 'error', 'trace'];
|
|
213
|
+
const levelMap = { log: 'INFO', info: 'INFO', debug: 'DEBUG', warn: 'WARNING', error: 'ERROR', trace: 'DEBUG' };
|
|
214
|
+
|
|
215
|
+
for (const method of methods) {
|
|
216
|
+
_originals[`console_${method}`] = console[method];
|
|
217
|
+
console[method] = function (...args) {
|
|
218
|
+
// Always call original first
|
|
219
|
+
_originals[`console_${method}`].apply(console, args);
|
|
220
|
+
|
|
221
|
+
// Prevent recursion
|
|
222
|
+
if (_sending) return;
|
|
223
|
+
|
|
224
|
+
// Sampling
|
|
225
|
+
if (Math.random() > _config.logSamplingRate) return;
|
|
226
|
+
|
|
227
|
+
const { message, errorObj } = _formatArgs(args);
|
|
228
|
+
const meta = { type: `console_${method}` };
|
|
229
|
+
if (errorObj) {
|
|
230
|
+
meta.error = { name: errorObj.name, message: errorObj.message, stack: errorObj.stack };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_enqueue(levelMap[method] || 'INFO', message, meta);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
239
|
+
// EXCEPTION & REJECTION CAPTURE
|
|
240
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
241
|
+
|
|
242
|
+
function _installExceptionCapture() {
|
|
243
|
+
process.on('uncaughtException', (err, origin) => {
|
|
244
|
+
_enqueue('ERROR', `Uncaught Exception: ${err.message}`, {
|
|
245
|
+
type: 'uncaught_exception',
|
|
246
|
+
origin,
|
|
247
|
+
error: { name: err.name, message: err.message, stack: err.stack },
|
|
248
|
+
});
|
|
249
|
+
_flush(); // Flush immediately — process may die
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _installRejectionCapture() {
|
|
254
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
255
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
256
|
+
_enqueue('ERROR', `Unhandled Promise Rejection: ${err.message}`, {
|
|
257
|
+
type: 'unhandled_rejection',
|
|
258
|
+
error: { name: err.name, message: err.message, stack: err.stack },
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _installWarningCapture() {
|
|
264
|
+
process.on('warning', (warning) => {
|
|
265
|
+
_enqueue('WARNING', `Process Warning: ${warning.message}`, {
|
|
266
|
+
type: 'process_warning',
|
|
267
|
+
warningName: warning.name,
|
|
268
|
+
warningCode: warning.code,
|
|
269
|
+
stack: warning.stack,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Capture deprecations
|
|
274
|
+
const origEmit = process.emit;
|
|
275
|
+
process.emit = function (event, ...args) {
|
|
276
|
+
if (event === 'deprecation' && args[0]) {
|
|
277
|
+
const dep = args[0];
|
|
278
|
+
_enqueue('WARNING', `Deprecation: ${dep.message || dep}`, {
|
|
279
|
+
type: 'deprecation',
|
|
280
|
+
code: dep.code,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return origEmit.apply(this, [event, ...args]);
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
288
|
+
// HTTP CAPTURE — intercepts ALL outgoing http/https requests
|
|
289
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
290
|
+
|
|
291
|
+
function _installHttpCapture() {
|
|
292
|
+
try {
|
|
293
|
+
const http = require('http');
|
|
294
|
+
const https = require('https');
|
|
295
|
+
|
|
296
|
+
_wrapHttpModule(http, 'http');
|
|
297
|
+
_wrapHttpModule(https, 'https');
|
|
298
|
+
} catch (err) {
|
|
299
|
+
_debugLog('Could not wrap http/https modules:', err.message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _wrapHttpModule(mod, protocol) {
|
|
304
|
+
const originalRequest = mod.request;
|
|
305
|
+
const originalGet = mod.get;
|
|
306
|
+
|
|
307
|
+
mod.request = function (...args) {
|
|
308
|
+
const req = originalRequest.apply(this, args);
|
|
309
|
+
return _instrumentRequest(req, args, protocol);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
mod.get = function (...args) {
|
|
313
|
+
const req = originalGet.apply(this, args);
|
|
314
|
+
return _instrumentRequest(req, args, protocol);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _instrumentRequest(req, args, protocol) {
|
|
319
|
+
// Parse URL
|
|
320
|
+
let url = '', method = 'GET', hostname = '';
|
|
321
|
+
if (typeof args[0] === 'string' || args[0] instanceof URL) {
|
|
322
|
+
const parsed = new URL(typeof args[0] === 'string' ? args[0] : args[0].href);
|
|
323
|
+
url = parsed.href;
|
|
324
|
+
hostname = parsed.hostname;
|
|
325
|
+
} else if (args[0] && typeof args[0] === 'object') {
|
|
326
|
+
hostname = args[0].hostname || args[0].host || 'unknown';
|
|
327
|
+
const path = args[0].path || '/';
|
|
328
|
+
url = `${protocol}://${hostname}${path}`;
|
|
329
|
+
method = args[0].method || 'GET';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Skip Overcast's own calls
|
|
333
|
+
if (url.includes(_config.baseUrl)) return req;
|
|
334
|
+
|
|
335
|
+
// Sampling
|
|
336
|
+
if (Math.random() > _config.httpSamplingRate) return req;
|
|
337
|
+
|
|
338
|
+
const startTime = Date.now();
|
|
339
|
+
const bodyChunks = [];
|
|
340
|
+
|
|
341
|
+
// Capture request body
|
|
342
|
+
const origWrite = req.write.bind(req);
|
|
343
|
+
req.write = function (chunk, ...rest) {
|
|
344
|
+
if (chunk) bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
345
|
+
return origWrite(chunk, ...rest);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
req.on('response', (res) => {
|
|
349
|
+
const duration = Date.now() - startTime;
|
|
350
|
+
const responseChunks = [];
|
|
351
|
+
|
|
352
|
+
res.on('data', (chunk) => responseChunks.push(chunk));
|
|
353
|
+
res.on('end', () => {
|
|
354
|
+
let requestBody = _parseBody(bodyChunks);
|
|
355
|
+
let responseBody = _parseBody(responseChunks);
|
|
356
|
+
|
|
357
|
+
const meta = {
|
|
358
|
+
type: 'http_outgoing',
|
|
359
|
+
protocol,
|
|
360
|
+
method,
|
|
361
|
+
url,
|
|
362
|
+
hostname,
|
|
363
|
+
statusCode: res.statusCode,
|
|
364
|
+
statusMessage: res.statusMessage,
|
|
365
|
+
duration,
|
|
366
|
+
requestBody,
|
|
367
|
+
responseBody,
|
|
368
|
+
responseHeaders: _sanitizeHeaders(res.headers),
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// Slow request
|
|
372
|
+
if (duration > _config.slowRequestThreshold) {
|
|
373
|
+
_enqueue('WARNING', `Slow outgoing request: ${method} ${url} took ${duration}ms`, { ...meta, type: 'slow_http_outgoing' });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Failed request
|
|
377
|
+
if (res.statusCode >= 400) {
|
|
378
|
+
const level = res.statusCode >= 500 ? 'ERROR' : 'WARNING';
|
|
379
|
+
_enqueue(level, `HTTP ${res.statusCode}: ${method} ${url}`, meta);
|
|
380
|
+
} else {
|
|
381
|
+
_enqueue('DEBUG', `HTTP ${res.statusCode}: ${method} ${url} (${duration}ms)`, meta);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
req.on('error', (err) => {
|
|
387
|
+
const duration = Date.now() - startTime;
|
|
388
|
+
_enqueue('ERROR', `Network error: ${method} ${url} — ${err.message}`, {
|
|
389
|
+
type: 'http_network_error',
|
|
390
|
+
method, url, hostname, duration, protocol,
|
|
391
|
+
error: { name: err.name, message: err.message, stack: err.stack },
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
req.on('timeout', () => {
|
|
396
|
+
const duration = Date.now() - startTime;
|
|
397
|
+
_enqueue('ERROR', `Request timeout: ${method} ${url} after ${duration}ms`, {
|
|
398
|
+
type: 'http_timeout',
|
|
399
|
+
method, url, hostname, duration, protocol,
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
return req;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
407
|
+
// PERFORMANCE CAPTURE — memory, event-loop lag, CPU
|
|
408
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
409
|
+
|
|
410
|
+
function _installPerformanceCapture() {
|
|
411
|
+
let lastLoopCheck = Date.now();
|
|
412
|
+
|
|
413
|
+
_perfTimer = setInterval(() => {
|
|
414
|
+
// Memory usage
|
|
415
|
+
const mem = process.memoryUsage();
|
|
416
|
+
const heapUsedMB = (mem.heapUsed / 1048576).toFixed(1);
|
|
417
|
+
const heapTotalMB = (mem.heapTotal / 1048576).toFixed(1);
|
|
418
|
+
const rssMB = (mem.rss / 1048576).toFixed(1);
|
|
419
|
+
const heapPercent = (mem.heapUsed / mem.heapTotal) * 100;
|
|
420
|
+
|
|
421
|
+
if (heapPercent > _config.memoryWarningPercent) {
|
|
422
|
+
_enqueue('WARNING', `High memory usage: ${heapPercent.toFixed(1)}% heap (${heapUsedMB}MB / ${heapTotalMB}MB)`, {
|
|
423
|
+
type: 'performance_memory',
|
|
424
|
+
heapUsedMB: +heapUsedMB,
|
|
425
|
+
heapTotalMB: +heapTotalMB,
|
|
426
|
+
rssMB: +rssMB,
|
|
427
|
+
heapPercent: +heapPercent.toFixed(1),
|
|
428
|
+
externalMB: +(mem.external / 1048576).toFixed(1),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Event loop lag
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
const lag = now - lastLoopCheck - 30000; // Timer fires every 30s
|
|
435
|
+
lastLoopCheck = now;
|
|
436
|
+
if (lag > _config.eventLoopLagThreshold) {
|
|
437
|
+
_enqueue('WARNING', `Event loop lag: ${lag}ms`, {
|
|
438
|
+
type: 'performance_event_loop_lag',
|
|
439
|
+
lagMs: lag,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Active handles/requests (leak detection)
|
|
444
|
+
const handles = process._getActiveHandles?.()?.length || 0;
|
|
445
|
+
const requests = process._getActiveRequests?.()?.length || 0;
|
|
446
|
+
if (handles > 500) {
|
|
447
|
+
_enqueue('WARNING', `High active handles: ${handles}`, {
|
|
448
|
+
type: 'performance_handle_leak',
|
|
449
|
+
activeHandles: handles,
|
|
450
|
+
activeRequests: requests,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}, 30000);
|
|
454
|
+
|
|
455
|
+
if (_perfTimer.unref) _perfTimer.unref();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
459
|
+
// SHUTDOWN HANDLERS — flush before process exits
|
|
460
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
461
|
+
|
|
462
|
+
function _installShutdownHandlers() {
|
|
463
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
|
|
464
|
+
|
|
465
|
+
for (const sig of signals) {
|
|
466
|
+
process.on(sig, () => {
|
|
467
|
+
_enqueue('INFO', `Process received ${sig}`, { type: 'process_signal', signal: sig });
|
|
468
|
+
_flush();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
process.on('beforeExit', () => {
|
|
473
|
+
_flush();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
process.on('exit', (code) => {
|
|
477
|
+
// Synchronous flush on exit (best effort)
|
|
478
|
+
_enqueue('INFO', `Process exiting with code ${code}`, { type: 'process_exit', exitCode: code });
|
|
479
|
+
_flushSync();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
484
|
+
// DATA SANITIZATION — redact PII, secrets, tokens
|
|
485
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
486
|
+
|
|
487
|
+
function _sanitize(data) {
|
|
488
|
+
if (!_config.sanitize) return data;
|
|
489
|
+
if (data === null || data === undefined) return data;
|
|
490
|
+
|
|
491
|
+
if (typeof data === 'string') return _sanitizeString(data);
|
|
492
|
+
if (typeof data === 'number' || typeof data === 'boolean') return data;
|
|
493
|
+
if (data instanceof Error) {
|
|
494
|
+
return { name: data.name, message: _sanitizeString(data.message), stack: data.stack ? _sanitizeString(data.stack) : undefined };
|
|
495
|
+
}
|
|
496
|
+
if (Array.isArray(data)) return data.map(d => _sanitize(d));
|
|
497
|
+
|
|
498
|
+
if (typeof data === 'object') {
|
|
499
|
+
const out = {};
|
|
500
|
+
for (const key of Object.keys(data)) {
|
|
501
|
+
const lk = key.toLowerCase();
|
|
502
|
+
if (_isSensitiveKey(lk)) {
|
|
503
|
+
out[key] = '[REDACTED]';
|
|
504
|
+
} else {
|
|
505
|
+
try { out[key] = _sanitize(data[key]); } catch { out[key] = '[Unserializable]'; }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
return data;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function _isSensitiveKey(k) {
|
|
514
|
+
return /password|passwd|pwd|secret|token|auth|apikey|api_key|access_key|private_key|credential|credit|card|ssn|social|cookie|session_id/.test(k);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function _sanitizeString(str) {
|
|
518
|
+
if (typeof str !== 'string') return str;
|
|
519
|
+
return str
|
|
520
|
+
// Passwords in key=value
|
|
521
|
+
.replace(/\b(password|passwd|pwd|secret|pass)\s*[:=]\s*["']?([^\s"',;}\]]+)["']?/gi, '$1=[REDACTED]')
|
|
522
|
+
// Bearer tokens
|
|
523
|
+
.replace(/\b(Bearer)\s+[A-Za-z0-9\-._~+\/]+=*/gi, '$1 [REDACTED]')
|
|
524
|
+
// JWTs
|
|
525
|
+
.replace(/\beyJ[A-Za-z0-9\-_=]+\.eyJ[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_.+\/=]*/g, '[REDACTED_JWT]')
|
|
526
|
+
// API keys (sk_, pk_, etc.)
|
|
527
|
+
.replace(/\b(sk_|pk_|api_|key_|token_)[A-Za-z0-9]{16,}/gi, '[REDACTED_KEY]')
|
|
528
|
+
// AWS keys
|
|
529
|
+
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16}/g, '[REDACTED_AWS]')
|
|
530
|
+
// Emails
|
|
531
|
+
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[REDACTED_EMAIL]')
|
|
532
|
+
// Phone numbers
|
|
533
|
+
.replace(/(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g, '[REDACTED_PHONE]')
|
|
534
|
+
// Credit card numbers
|
|
535
|
+
.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, '[REDACTED_CC]')
|
|
536
|
+
// SSN
|
|
537
|
+
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED_SSN]')
|
|
538
|
+
// URL tokens/sessions
|
|
539
|
+
.replace(/([?&])(session|sess|sid|token|auth|key|apikey)=([^&\s]+)/gi, '$1$2=[REDACTED]')
|
|
540
|
+
// Authorization headers in strings
|
|
541
|
+
.replace(/(Authorization|X-Auth-Token|X-API-Key)\s*:\s*([^\s,;]+)/gi, '$1: [REDACTED]');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function _sanitizeHeaders(headers) {
|
|
545
|
+
if (!headers || !_config.sanitize) return headers;
|
|
546
|
+
const out = { ...headers };
|
|
547
|
+
const sensitive = ['authorization', 'cookie', 'set-cookie', 'x-auth-token', 'x-api-key', 'proxy-authorization'];
|
|
548
|
+
for (const key of sensitive) {
|
|
549
|
+
if (out[key]) out[key] = '[REDACTED]';
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
555
|
+
// ENCRYPTION — AES-256-GCM payload encryption
|
|
556
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
557
|
+
|
|
558
|
+
function _encrypt(plaintext) {
|
|
559
|
+
if (!_config.encryptPayload || !_config.encryptionKey) return plaintext;
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const crypto = require('crypto');
|
|
563
|
+
const key = Buffer.from(_config.encryptionKey, 'hex');
|
|
564
|
+
const iv = crypto.randomBytes(12);
|
|
565
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
566
|
+
|
|
567
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
|
568
|
+
encrypted += cipher.final('base64');
|
|
569
|
+
const authTag = cipher.getAuthTag().toString('base64');
|
|
570
|
+
|
|
571
|
+
return JSON.stringify({
|
|
572
|
+
encrypted: true,
|
|
573
|
+
algorithm: 'aes-256-gcm',
|
|
574
|
+
iv: iv.toString('base64'),
|
|
575
|
+
authTag,
|
|
576
|
+
data: encrypted,
|
|
577
|
+
});
|
|
578
|
+
} catch (err) {
|
|
579
|
+
_debugLog('Encryption failed, sending unencrypted:', err.message);
|
|
580
|
+
return plaintext;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
585
|
+
// TRANSPORT — batching, flushing, sending to Overcast
|
|
586
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
587
|
+
|
|
588
|
+
function _enqueue(level, message, metadata = {}) {
|
|
589
|
+
if (!_initialized) return;
|
|
590
|
+
if (_sending) return; // Prevent recursion
|
|
591
|
+
|
|
592
|
+
// Sanitize
|
|
593
|
+
const sanitizedMessage = _sanitize(message);
|
|
594
|
+
const sanitizedMetadata = _sanitize(metadata);
|
|
595
|
+
|
|
596
|
+
const entry = {
|
|
597
|
+
timestamp: new Date().toISOString(),
|
|
598
|
+
level: level.toUpperCase(),
|
|
599
|
+
message: typeof sanitizedMessage === 'string' ? sanitizedMessage : JSON.stringify(sanitizedMessage),
|
|
600
|
+
service: _config.serviceName,
|
|
601
|
+
environment: _config.environment,
|
|
602
|
+
raw_log: typeof sanitizedMessage === 'string' ? sanitizedMessage : JSON.stringify(sanitizedMessage),
|
|
603
|
+
metadata: {
|
|
604
|
+
...sanitizedMetadata,
|
|
605
|
+
sdkLanguage: 'node',
|
|
606
|
+
sdkVersion: '1.0.0',
|
|
607
|
+
nodeVersion: process.version,
|
|
608
|
+
pid: process.pid,
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// If error metadata has a stack trace, append it
|
|
613
|
+
if (sanitizedMetadata.error?.stack) {
|
|
614
|
+
entry.message += `\n\nStack trace:\n${sanitizedMetadata.error.stack}`;
|
|
615
|
+
entry.raw_log = entry.message;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
_buffer.push(entry);
|
|
619
|
+
|
|
620
|
+
// Drop oldest if buffer is too large
|
|
621
|
+
if (_buffer.length > _config.maxBufferSize) {
|
|
622
|
+
_buffer = _buffer.slice(_buffer.length - _config.maxBufferSize);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Flush immediately for errors, or when batch is full
|
|
626
|
+
if (level === 'ERROR' || _buffer.length >= _config.batchSize) {
|
|
627
|
+
_flush();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function _flush() {
|
|
632
|
+
if (_buffer.length === 0) return Promise.resolve();
|
|
633
|
+
|
|
634
|
+
const logs = _buffer.splice(0, _config.batchSize);
|
|
635
|
+
|
|
636
|
+
let body = JSON.stringify({
|
|
637
|
+
api_key: _config.apiKey,
|
|
638
|
+
source_type: 'application',
|
|
639
|
+
source_description: `${_config.serviceName} (node/${process.version})`,
|
|
640
|
+
logs,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Encrypt if configured
|
|
644
|
+
if (_config.encryptPayload) {
|
|
645
|
+
body = _encrypt(body);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return _send(body).catch(err => {
|
|
649
|
+
_debugLog('Flush failed:', err.message);
|
|
650
|
+
// Re-enqueue failed logs (put them back at front)
|
|
651
|
+
_buffer.unshift(...logs);
|
|
652
|
+
if (_buffer.length > _config.maxBufferSize) {
|
|
653
|
+
_buffer = _buffer.slice(_buffer.length - _config.maxBufferSize);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function _flushSync() {
|
|
659
|
+
// Best-effort synchronous flush using child_process
|
|
660
|
+
if (_buffer.length === 0) return;
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
const { execSync } = require('child_process');
|
|
664
|
+
const logs = _buffer.splice(0);
|
|
665
|
+
const body = JSON.stringify({
|
|
666
|
+
api_key: _config.apiKey,
|
|
667
|
+
source_type: 'application',
|
|
668
|
+
source_description: `${_config.serviceName} (node/${process.version})`,
|
|
669
|
+
logs,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const url = `${_config.baseUrl}${INGEST_ENDPOINT}`;
|
|
673
|
+
// Use curl for synchronous send
|
|
674
|
+
execSync(`curl -s -X POST "${url}" -H "Content-Type: application/json" -H "X-Overcast-Key: ${_config.apiKey}" -d '${body.replace(/'/g, "'\\''")}'`, {
|
|
675
|
+
timeout: 5000,
|
|
676
|
+
stdio: 'ignore',
|
|
677
|
+
});
|
|
678
|
+
} catch {
|
|
679
|
+
// Best effort — process is exiting
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function _send(body) {
|
|
684
|
+
_sending = true;
|
|
685
|
+
|
|
686
|
+
return new Promise((resolve, reject) => {
|
|
687
|
+
try {
|
|
688
|
+
const url = new URL(`${_config.baseUrl}${INGEST_ENDPOINT}`);
|
|
689
|
+
const isHttps = url.protocol === 'https:';
|
|
690
|
+
const mod = isHttps ? require('https') : require('http');
|
|
691
|
+
|
|
692
|
+
const req = mod.request({
|
|
693
|
+
hostname: url.hostname,
|
|
694
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
695
|
+
path: url.pathname,
|
|
696
|
+
method: 'POST',
|
|
697
|
+
headers: {
|
|
698
|
+
'Content-Type': _config.encryptPayload ? 'application/octet-stream' : 'application/json',
|
|
699
|
+
'Content-Length': Buffer.byteLength(body),
|
|
700
|
+
'X-Overcast-Key': _config.apiKey,
|
|
701
|
+
'X-Overcast-Service': _config.serviceName,
|
|
702
|
+
'X-Overcast-SDK': 'node/1.0.0',
|
|
703
|
+
'User-Agent': `overcast-node/1.0.0 node/${process.version}`,
|
|
704
|
+
},
|
|
705
|
+
// TLS security
|
|
706
|
+
minVersion: 'TLSv1.2',
|
|
707
|
+
rejectUnauthorized: true,
|
|
708
|
+
}, (res) => {
|
|
709
|
+
res.resume(); // Consume response
|
|
710
|
+
_sending = false;
|
|
711
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
712
|
+
resolve();
|
|
713
|
+
} else {
|
|
714
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
req.on('error', (err) => { _sending = false; reject(err); });
|
|
719
|
+
req.setTimeout(10000, () => { _sending = false; req.destroy(); reject(new Error('Timeout')); });
|
|
720
|
+
req.write(body);
|
|
721
|
+
req.end();
|
|
722
|
+
} catch (err) {
|
|
723
|
+
_sending = false;
|
|
724
|
+
reject(err);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
730
|
+
// HELPERS
|
|
731
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
732
|
+
|
|
733
|
+
function _formatArgs(args) {
|
|
734
|
+
let errorObj = null;
|
|
735
|
+
const parts = [];
|
|
736
|
+
|
|
737
|
+
for (const arg of args) {
|
|
738
|
+
if (arg instanceof Error) {
|
|
739
|
+
if (!errorObj) errorObj = arg;
|
|
740
|
+
parts.push(arg.message);
|
|
741
|
+
} else if (typeof arg === 'object') {
|
|
742
|
+
try { parts.push(JSON.stringify(arg)); } catch { parts.push('[Object]'); }
|
|
743
|
+
} else {
|
|
744
|
+
parts.push(String(arg));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return { message: parts.join(' '), errorObj };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function _parseBody(chunks) {
|
|
752
|
+
if (!chunks || chunks.length === 0) return null;
|
|
753
|
+
try {
|
|
754
|
+
const buf = Buffer.concat(chunks);
|
|
755
|
+
const str = buf.toString('utf8').substring(0, 2000);
|
|
756
|
+
try { return JSON.parse(str); } catch { return str; }
|
|
757
|
+
} catch { return null; }
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function _debugLog(...args) {
|
|
761
|
+
if (_config.debug && _originals.console_log) {
|
|
762
|
+
_originals.console_log.apply(console, ['[Overcast]', ...args]);
|
|
763
|
+
} else if (_config.debug) {
|
|
764
|
+
// Before console capture is installed
|
|
765
|
+
process.stdout.write(`[Overcast] ${args.join(' ')}\n`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
770
|
+
// EXPORTS
|
|
771
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
772
|
+
|
|
773
|
+
module.exports = {
|
|
774
|
+
init,
|
|
775
|
+
error,
|
|
776
|
+
warn,
|
|
777
|
+
info,
|
|
778
|
+
debug,
|
|
779
|
+
captureException,
|
|
780
|
+
middleware,
|
|
781
|
+
flush,
|
|
782
|
+
shutdown,
|
|
783
|
+
getBufferSize: () => _buffer.length,
|
|
784
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@overcastsre/node",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Overcast SRE monitoring SDK for Node.js — one-line install, captures everything.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["overcast", "monitoring", "logging", "error-tracking", "observability", "apm", "node"],
|
|
9
|
+
"engines": { "node": ">=14.0.0" },
|
|
10
|
+
"files": ["index.js", "index.d.ts", "README.md"],
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/overcast/sdk-node" },
|
|
12
|
+
"homepage": "https://overcastsre.com/docs/node"
|
|
13
|
+
}
|