@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/README.md +136 -0
- package/package.json +36 -0
- package/src/aggregator.js +219 -0
- package/src/config.js +67 -0
- package/src/dependencies.js +236 -0
- package/src/errors.js +132 -0
- package/src/index.js +175 -0
- package/src/instrumentation.js +208 -0
- package/src/normalizer.js +147 -0
- package/src/transport.js +214 -0
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
|
+
};
|