@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.
Files changed (3) hide show
  1. package/README.md +47 -0
  2. package/index.js +784 -0
  3. 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
+ }