@sentienguard/apm 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/src/errors.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Error Capture
3
+ * Captures unhandled exceptions and associates them with error counters.
4
+ *
5
+ * Stack traces are captured locally but NOT sent to backend in v1.
6
+ * Only error counters are aggregated and sent.
7
+ */
8
+
9
+ import { getAggregator } from './aggregator.js';
10
+ import { debug, warn } from './config.js';
11
+
12
+ let isCapturing = false;
13
+ let originalUncaughtException = null;
14
+ let originalUnhandledRejection = null;
15
+
16
+ /**
17
+ * Handle uncaught exception
18
+ */
19
+ function handleUncaughtException(error) {
20
+ debug('Captured uncaught exception:', error.message);
21
+
22
+ const aggregator = getAggregator();
23
+ aggregator.recordError();
24
+
25
+ // Log locally but don't crash (let the original handler decide)
26
+ if (originalUncaughtException) {
27
+ // If there was a previous handler, call it
28
+ originalUncaughtException(error);
29
+ } else {
30
+ // Default behavior: log and continue
31
+ // We don't re-throw to avoid double handling
32
+ warn('Uncaught exception:', error.message);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Handle unhandled promise rejection
38
+ */
39
+ function handleUnhandledRejection(reason, promise) {
40
+ const message = reason instanceof Error ? reason.message : String(reason);
41
+ debug('Captured unhandled rejection:', message);
42
+
43
+ const aggregator = getAggregator();
44
+ aggregator.recordError();
45
+
46
+ // Call previous handler if exists
47
+ if (originalUnhandledRejection) {
48
+ originalUnhandledRejection(reason, promise);
49
+ } else {
50
+ warn('Unhandled rejection:', message);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Start capturing unhandled errors
56
+ */
57
+ export function startErrorCapture() {
58
+ if (isCapturing) {
59
+ debug('Error capture already active, skipping');
60
+ return;
61
+ }
62
+
63
+ // Store any existing handlers
64
+ const existingUncaught = process.listeners('uncaughtException');
65
+ const existingRejection = process.listeners('unhandledRejection');
66
+
67
+ if (existingUncaught.length > 0) {
68
+ originalUncaughtException = existingUncaught[existingUncaught.length - 1];
69
+ }
70
+ if (existingRejection.length > 0) {
71
+ originalUnhandledRejection = existingRejection[existingRejection.length - 1];
72
+ }
73
+
74
+ // Add our handlers
75
+ process.on('uncaughtException', handleUncaughtException);
76
+ process.on('unhandledRejection', handleUnhandledRejection);
77
+
78
+ isCapturing = true;
79
+ debug('Error capture enabled');
80
+ }
81
+
82
+ /**
83
+ * Stop capturing errors (for cleanup/testing)
84
+ */
85
+ export function stopErrorCapture() {
86
+ if (!isCapturing) return;
87
+
88
+ process.removeListener('uncaughtException', handleUncaughtException);
89
+ process.removeListener('unhandledRejection', handleUnhandledRejection);
90
+
91
+ originalUncaughtException = null;
92
+ originalUnhandledRejection = null;
93
+
94
+ isCapturing = false;
95
+ debug('Error capture disabled');
96
+ }
97
+
98
+ /**
99
+ * Create Express error middleware
100
+ * Use this as the last middleware to capture Express errors
101
+ */
102
+ export function expressErrorMiddleware() {
103
+ return function sentienguardErrorMiddleware(err, req, res, next) {
104
+ debug('Captured Express error:', err.message);
105
+
106
+ const aggregator = getAggregator();
107
+ aggregator.recordError();
108
+
109
+ // Pass to next error handler
110
+ next(err);
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Create Fastify error handler hook
116
+ */
117
+ export function fastifyErrorHandler(error, request, reply) {
118
+ debug('Captured Fastify error:', error.message);
119
+
120
+ const aggregator = getAggregator();
121
+ aggregator.recordError();
122
+
123
+ // Re-throw to let Fastify handle it
124
+ throw error;
125
+ }
126
+
127
+ export default {
128
+ startErrorCapture,
129
+ stopErrorCapture,
130
+ expressErrorMiddleware,
131
+ fastifyErrorHandler
132
+ };
package/src/index.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SentienGuard APM SDK
3
+ *
4
+ * Minimal, production-safe APM that runs inside client applications
5
+ * and sends aggregated metrics to the SentienGuard backend.
6
+ *
7
+ * Usage:
8
+ * import "@sentienguard/apm";
9
+ *
10
+ * That's it. No function calls, no setup code, no decorators.
11
+ *
12
+ * Configuration via environment variables:
13
+ * SENTIENGUARD_APM_KEY=xxxx (required)
14
+ * SENTIENGUARD_SERVICE=my-api (required)
15
+ * SENTIENGUARD_ENV=production (optional, default: production)
16
+ * SENTIENGUARD_ENDPOINT=https://... (optional)
17
+ * SENTIENGUARD_FLUSH_INTERVAL=10 (optional, seconds)
18
+ *
19
+ * No config → SDK disables itself silently.
20
+ */
21
+
22
+ import config, { isEnabled, debug, warn, getConfig } from './config.js';
23
+ import { instrumentHttp, expressMiddleware, fastifyPlugin } from './instrumentation.js';
24
+ import { instrumentDependencies } from './dependencies.js';
25
+ import { startErrorCapture, expressErrorMiddleware, fastifyErrorHandler } from './errors.js';
26
+ import { startFlushing, stopFlushing, finalFlush, flush } from './transport.js';
27
+ import { getAggregator } from './aggregator.js';
28
+ import { normalizeRoute, extractRoute, RouteRegistry } from './normalizer.js';
29
+
30
+ let isInitialized = false;
31
+
32
+ /**
33
+ * Initialize the SDK
34
+ * Called automatically on import
35
+ */
36
+ function initialize() {
37
+ if (isInitialized) {
38
+ debug('SDK already initialized');
39
+ return;
40
+ }
41
+
42
+ // Check if SDK should be enabled
43
+ if (!isEnabled()) {
44
+ // Silently disable - this is expected behavior
45
+ debug('SDK disabled (missing API key or service name)');
46
+ return;
47
+ }
48
+
49
+ // Warn if this import is not first (other modules may have created servers already)
50
+ // We can't reliably detect this, so just log for debugging
51
+ debug(`Initializing SDK for service: ${config.service}`);
52
+ debug(`Environment: ${config.environment}`);
53
+ debug(`Endpoint: ${config.endpoint}`);
54
+ debug(`Flush interval: ${config.flushInterval}s`);
55
+
56
+ // Instrument HTTP (incoming requests)
57
+ instrumentHttp();
58
+
59
+ // Instrument dependencies (outgoing requests)
60
+ instrumentDependencies();
61
+
62
+ // Start error capture
63
+ startErrorCapture();
64
+
65
+ // Start periodic flush
66
+ startFlushing();
67
+
68
+ // Handle graceful shutdown
69
+ setupGracefulShutdown();
70
+
71
+ isInitialized = true;
72
+ debug('SDK initialized successfully');
73
+ }
74
+
75
+ /**
76
+ * Setup graceful shutdown handlers
77
+ */
78
+ function setupGracefulShutdown() {
79
+ const shutdown = async (signal) => {
80
+ debug(`Received ${signal}, performing graceful shutdown`);
81
+
82
+ // Stop accepting new data
83
+ stopFlushing();
84
+
85
+ // Final flush
86
+ await finalFlush();
87
+
88
+ debug('Shutdown complete');
89
+ };
90
+
91
+ // Handle common shutdown signals
92
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
93
+ process.once('SIGINT', () => shutdown('SIGINT'));
94
+
95
+ // Handle process exit
96
+ process.once('beforeExit', () => shutdown('beforeExit'));
97
+ }
98
+
99
+ /**
100
+ * Shutdown the SDK
101
+ * Call this before process exit for clean shutdown
102
+ */
103
+ async function shutdown() {
104
+ if (!isInitialized) return;
105
+
106
+ debug('Shutting down SDK');
107
+
108
+ stopFlushing();
109
+ await finalFlush();
110
+
111
+ isInitialized = false;
112
+ debug('SDK shutdown complete');
113
+ }
114
+
115
+ /**
116
+ * Get current SDK status
117
+ */
118
+ function getStatus() {
119
+ const aggregator = getAggregator();
120
+ const stats = aggregator.getStats();
121
+
122
+ return {
123
+ enabled: isEnabled(),
124
+ initialized: isInitialized,
125
+ config: {
126
+ service: config.service,
127
+ environment: config.environment,
128
+ flushInterval: config.flushInterval
129
+ },
130
+ stats
131
+ };
132
+ }
133
+
134
+ // Auto-initialize on import
135
+ initialize();
136
+
137
+ // Export for advanced usage
138
+ export {
139
+ // Core functions
140
+ initialize,
141
+ shutdown,
142
+ getStatus,
143
+ flush,
144
+
145
+ // Config
146
+ getConfig,
147
+ isEnabled,
148
+
149
+ // Middleware for frameworks (optional, for better route extraction)
150
+ expressMiddleware,
151
+ expressErrorMiddleware,
152
+ fastifyPlugin,
153
+ fastifyErrorHandler,
154
+
155
+ // Utilities (for custom instrumentation)
156
+ normalizeRoute,
157
+ extractRoute,
158
+ RouteRegistry,
159
+ getAggregator
160
+ };
161
+
162
+ // Default export
163
+ export default {
164
+ initialize,
165
+ shutdown,
166
+ getStatus,
167
+ flush,
168
+ expressMiddleware,
169
+ expressErrorMiddleware,
170
+ fastifyPlugin,
171
+ fastifyErrorHandler,
172
+ normalizeRoute,
173
+ extractRoute,
174
+ getAggregator
175
+ };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * HTTP Request Auto-Instrumentation
3
+ * Automatically instruments incoming HTTP requests for Express, Fastify, and plain Node.js http.
4
+ *
5
+ * Captures:
6
+ * - Method
7
+ * - Normalized route
8
+ * - Status code
9
+ * - Response time
10
+ * - Error flag
11
+ */
12
+
13
+ import http from 'http';
14
+ import https from 'https';
15
+ import { extractRoute, normalizeRoute } from './normalizer.js';
16
+ import { getAggregator } from './aggregator.js';
17
+ import { debug } from './config.js';
18
+
19
+ let isInstrumented = false;
20
+ let originalHttpCreateServer = null;
21
+ let originalHttpsCreateServer = null;
22
+
23
+ /**
24
+ * Wrap a request handler to capture metrics
25
+ */
26
+ function wrapRequestHandler(handler) {
27
+ return function wrappedHandler(req, res) {
28
+ const startTime = process.hrtime.bigint();
29
+
30
+ // Store original end method
31
+ const originalEnd = res.end;
32
+
33
+ // Override res.end to capture timing
34
+ res.end = function (...args) {
35
+ const endTime = process.hrtime.bigint();
36
+ const latencyMs = Number(endTime - startTime) / 1e6; // Convert nanoseconds to milliseconds
37
+
38
+ // Extract route (after response, frameworks may have populated route info)
39
+ const route = extractRoute(req);
40
+ const method = req.method || 'GET';
41
+ const statusCode = res.statusCode || 200;
42
+ const isError = statusCode >= 400;
43
+
44
+ // Record the metric
45
+ const aggregator = getAggregator();
46
+ aggregator.recordRequest(method, route, latencyMs, isError);
47
+
48
+ debug(`Request: ${method} ${route} ${statusCode} ${latencyMs.toFixed(2)}ms`);
49
+
50
+ // Call original end
51
+ return originalEnd.apply(this, args);
52
+ };
53
+
54
+ // Call original handler
55
+ return handler.call(this, req, res);
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Wrap a server's request listener
61
+ */
62
+ function wrapServer(server) {
63
+ const listeners = server.listeners('request');
64
+
65
+ // Remove existing listeners
66
+ server.removeAllListeners('request');
67
+
68
+ // Re-add wrapped listeners
69
+ for (const listener of listeners) {
70
+ server.on('request', wrapRequestHandler(listener));
71
+ }
72
+
73
+ // Also wrap future listeners
74
+ const originalOn = server.on.bind(server);
75
+ server.on = function (event, listener) {
76
+ if (event === 'request') {
77
+ return originalOn(event, wrapRequestHandler(listener));
78
+ }
79
+ return originalOn(event, listener);
80
+ };
81
+
82
+ return server;
83
+ }
84
+
85
+ /**
86
+ * Create instrumented http.createServer
87
+ */
88
+ function createInstrumentedCreateServer(original, protocol) {
89
+ return function instrumentedCreateServer(...args) {
90
+ const server = original.apply(this, args);
91
+
92
+ // If a request handler was passed, it's already attached
93
+ // We need to wrap the server after creation
94
+ setImmediate(() => {
95
+ wrapServer(server);
96
+ debug(`${protocol} server instrumented`);
97
+ });
98
+
99
+ return server;
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Instrument Node.js HTTP module
105
+ * This is the core instrumentation that works with any framework
106
+ */
107
+ export function instrumentHttp() {
108
+ if (isInstrumented) {
109
+ debug('HTTP already instrumented, skipping');
110
+ return;
111
+ }
112
+
113
+ // Store originals for cleanup
114
+ originalHttpCreateServer = http.createServer;
115
+ originalHttpsCreateServer = https.createServer;
116
+
117
+ // Patch http.createServer
118
+ http.createServer = createInstrumentedCreateServer(originalHttpCreateServer, 'HTTP');
119
+
120
+ // Patch https.createServer
121
+ https.createServer = createInstrumentedCreateServer(originalHttpsCreateServer, 'HTTPS');
122
+
123
+ isInstrumented = true;
124
+ debug('HTTP instrumentation enabled');
125
+ }
126
+
127
+ /**
128
+ * Create Express middleware for more accurate route extraction
129
+ * This is optional but provides better route patterns
130
+ */
131
+ export function expressMiddleware() {
132
+ return function sentienguardMiddleware(req, res, next) {
133
+ const startTime = process.hrtime.bigint();
134
+
135
+ // Use res.on('finish') for Express
136
+ res.on('finish', () => {
137
+ const endTime = process.hrtime.bigint();
138
+ const latencyMs = Number(endTime - startTime) / 1e6;
139
+
140
+ // Express populates req.route after routing
141
+ const route = extractRoute(req);
142
+ const method = req.method || 'GET';
143
+ const statusCode = res.statusCode || 200;
144
+ const isError = statusCode >= 400;
145
+
146
+ const aggregator = getAggregator();
147
+ aggregator.recordRequest(method, route, latencyMs, isError);
148
+
149
+ debug(`Express: ${method} ${route} ${statusCode} ${latencyMs.toFixed(2)}ms`);
150
+ });
151
+
152
+ next();
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Create Fastify plugin for instrumentation
158
+ */
159
+ export function fastifyPlugin(fastify, options, done) {
160
+ fastify.addHook('onRequest', async (request, reply) => {
161
+ request.sentienguardStart = process.hrtime.bigint();
162
+ });
163
+
164
+ fastify.addHook('onResponse', async (request, reply) => {
165
+ const endTime = process.hrtime.bigint();
166
+ const startTime = request.sentienguardStart || endTime;
167
+ const latencyMs = Number(endTime - startTime) / 1e6;
168
+
169
+ const route = extractRoute(request);
170
+ const method = request.method || 'GET';
171
+ const statusCode = reply.statusCode || 200;
172
+ const isError = statusCode >= 400;
173
+
174
+ const aggregator = getAggregator();
175
+ aggregator.recordRequest(method, route, latencyMs, isError);
176
+
177
+ debug(`Fastify: ${method} ${route} ${statusCode} ${latencyMs.toFixed(2)}ms`);
178
+ });
179
+
180
+ done();
181
+ }
182
+
183
+ // Mark as fastify plugin
184
+ fastifyPlugin[Symbol.for('skip-override')] = true;
185
+
186
+ /**
187
+ * Remove instrumentation (for testing/cleanup)
188
+ */
189
+ export function uninstrumentHttp() {
190
+ if (!isInstrumented) return;
191
+
192
+ if (originalHttpCreateServer) {
193
+ http.createServer = originalHttpCreateServer;
194
+ }
195
+ if (originalHttpsCreateServer) {
196
+ https.createServer = originalHttpsCreateServer;
197
+ }
198
+
199
+ isInstrumented = false;
200
+ debug('HTTP instrumentation disabled');
201
+ }
202
+
203
+ export default {
204
+ instrumentHttp,
205
+ expressMiddleware,
206
+ fastifyPlugin,
207
+ uninstrumentHttp
208
+ };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Route Normalizer
3
+ * Normalizes dynamic routes before aggregation to prevent cardinality explosion.
4
+ *
5
+ * Examples:
6
+ * /api/users/123 → /api/users/:id
7
+ * /api/orders/ab23f9 → /api/orders/:id
8
+ * /api/users/123/posts/456 → /api/users/:id/posts/:id
9
+ */
10
+
11
+ // Patterns for ID detection
12
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ const MONGO_ID_PATTERN = /^[0-9a-f]{24}$/i;
14
+ const NUMERIC_PATTERN = /^\d+$/;
15
+ const SHORT_HEX_PATTERN = /^[0-9a-f]{6,}$/i;
16
+
17
+ // Max route length to prevent abuse
18
+ const MAX_ROUTE_LENGTH = 200;
19
+
20
+ /**
21
+ * Check if a path segment looks like a dynamic ID
22
+ */
23
+ function isIdSegment(segment) {
24
+ if (!segment) return false;
25
+
26
+ // Check for common ID patterns
27
+ if (NUMERIC_PATTERN.test(segment)) return true;
28
+ if (UUID_PATTERN.test(segment)) return true;
29
+ if (MONGO_ID_PATTERN.test(segment)) return true;
30
+ if (SHORT_HEX_PATTERN.test(segment)) return true;
31
+
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Normalize a route path by replacing dynamic segments with :id
37
+ *
38
+ * @param {string} path - The URL path to normalize
39
+ * @returns {string} Normalized route path
40
+ */
41
+ export function normalizeRoute(path) {
42
+ if (!path || typeof path !== 'string') {
43
+ return '/unknown';
44
+ }
45
+
46
+ // Remove query string and hash
47
+ let cleanPath = path.split('?')[0].split('#')[0];
48
+
49
+ // Ensure path starts with /
50
+ if (!cleanPath.startsWith('/')) {
51
+ cleanPath = '/' + cleanPath;
52
+ }
53
+
54
+ // Truncate overly long paths
55
+ if (cleanPath.length > MAX_ROUTE_LENGTH) {
56
+ return '/unknown';
57
+ }
58
+
59
+ // Split into segments and normalize
60
+ const segments = cleanPath.split('/');
61
+ const normalizedSegments = segments.map(segment => {
62
+ if (isIdSegment(segment)) {
63
+ return ':id';
64
+ }
65
+ return segment;
66
+ });
67
+
68
+ const normalized = normalizedSegments.join('/');
69
+
70
+ // Clean up multiple slashes
71
+ return normalized.replace(/\/+/g, '/') || '/';
72
+ }
73
+
74
+ /**
75
+ * Extract route pattern from Express/Fastify request
76
+ * Prefers the framework's route pattern if available
77
+ *
78
+ * @param {object} req - HTTP request object
79
+ * @returns {string} Route pattern
80
+ */
81
+ export function extractRoute(req) {
82
+ // Express stores the matched route pattern
83
+ if (req.route && req.route.path) {
84
+ return req.baseUrl ? req.baseUrl + req.route.path : req.route.path;
85
+ }
86
+
87
+ // Fastify stores route info differently
88
+ if (req.routerPath) {
89
+ return req.routerPath;
90
+ }
91
+
92
+ // Fastify alternative
93
+ if (req.routeOptions && req.routeOptions.url) {
94
+ return req.routeOptions.url;
95
+ }
96
+
97
+ // Fall back to normalizing the URL
98
+ return normalizeRoute(req.url || req.originalUrl || '/');
99
+ }
100
+
101
+ /**
102
+ * Route registry to track and limit unique routes
103
+ */
104
+ export class RouteRegistry {
105
+ constructor(maxRoutes = 100) {
106
+ this.maxRoutes = maxRoutes;
107
+ this.routes = new Set();
108
+ }
109
+
110
+ /**
111
+ * Register a route and check if it should be tracked
112
+ * Returns the route if under limit, '/unknown' if limit exceeded
113
+ */
114
+ register(route) {
115
+ if (this.routes.has(route)) {
116
+ return route;
117
+ }
118
+
119
+ if (this.routes.size >= this.maxRoutes) {
120
+ // Bucket overflow routes as /unknown
121
+ return '/unknown';
122
+ }
123
+
124
+ this.routes.add(route);
125
+ return route;
126
+ }
127
+
128
+ /**
129
+ * Get count of unique routes
130
+ */
131
+ get size() {
132
+ return this.routes.size;
133
+ }
134
+
135
+ /**
136
+ * Reset the registry
137
+ */
138
+ clear() {
139
+ this.routes.clear();
140
+ }
141
+ }
142
+
143
+ export default {
144
+ normalizeRoute,
145
+ extractRoute,
146
+ RouteRegistry
147
+ };