@sentienguard/apm 1.0.0 → 1.0.3
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/package.json +22 -4
- package/src/aggregator.js +246 -2
- package/src/browser.js +107 -0
- package/src/circuitBreaker.js +254 -0
- package/src/config.js +40 -2
- package/src/index.js +36 -2
- package/src/mongodb.js +397 -0
- package/src/openai.js +520 -0
- package/src/transport.js +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentienguard/apm",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "SentienGuard APM SDK - Minimal, production-safe application performance monitoring",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"browser": "./src/browser.js",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"browser": "./src/browser.js",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
}
|
|
9
13
|
},
|
|
10
14
|
"scripts": {
|
|
11
15
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
@@ -16,7 +20,10 @@
|
|
|
16
20
|
"monitoring",
|
|
17
21
|
"performance",
|
|
18
22
|
"metrics",
|
|
19
|
-
"sentienguard"
|
|
23
|
+
"sentienguard",
|
|
24
|
+
"mongodb",
|
|
25
|
+
"circuit-breaker",
|
|
26
|
+
"observability"
|
|
20
27
|
],
|
|
21
28
|
"author": "SentienGuard",
|
|
22
29
|
"license": "MIT",
|
|
@@ -26,7 +33,18 @@
|
|
|
26
33
|
"engines": {
|
|
27
34
|
"node": ">=16.0.0"
|
|
28
35
|
},
|
|
29
|
-
"peerDependencies": {
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"mongoose": ">=6.0.0",
|
|
38
|
+
"opossum": ">=8.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"mongoose": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"opossum": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
30
48
|
"devDependencies": {
|
|
31
49
|
"jest": "^29.7.0"
|
|
32
50
|
},
|
package/src/aggregator.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Aggregation key: (service, method, route) for requests
|
|
7
7
|
* Aggregation key: (service, name, type) for dependencies
|
|
8
|
+
* Aggregation key: (collection, operation) for MongoDB operations
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { RouteRegistry } from './normalizer.js';
|
|
@@ -24,6 +25,20 @@ function createDependencyKey(name, type) {
|
|
|
24
25
|
return `${name}:${type}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Create an aggregation key for MongoDB operation metrics
|
|
30
|
+
*/
|
|
31
|
+
function createMongoKey(collection, operation) {
|
|
32
|
+
return `${collection}:${operation}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create an aggregation key for OpenAI operation metrics
|
|
37
|
+
*/
|
|
38
|
+
function createOpenAIKey(operation, model) {
|
|
39
|
+
return `${operation}:${model}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
/**
|
|
28
43
|
* Metrics Aggregator class
|
|
29
44
|
* Maintains in-memory aggregated metrics that are flushed periodically
|
|
@@ -44,6 +59,28 @@ export class MetricsAggregator {
|
|
|
44
59
|
// Dependency metrics: Map<key, {name, type, count, errorCount, latency: {sum, min, max}}>
|
|
45
60
|
this.dependencies = new Map();
|
|
46
61
|
|
|
62
|
+
// MongoDB operation metrics: Map<key, {collection, operation, count, errorCount, latency: {sum, min, max}}>
|
|
63
|
+
this.mongodbOperations = new Map();
|
|
64
|
+
|
|
65
|
+
// MongoDB slow queries: Array<{collection, operation, latency, timestamp}>
|
|
66
|
+
this.slowQueries = [];
|
|
67
|
+
|
|
68
|
+
// OpenAI operation metrics: Map<key, {operation, model, count, errorCount, latency, tokens, cost}>
|
|
69
|
+
this.openaiOperations = new Map();
|
|
70
|
+
|
|
71
|
+
// MongoDB connection pool stats (updated periodically, not reset)
|
|
72
|
+
if (!this.mongodbPoolStats) {
|
|
73
|
+
this.mongodbPoolStats = {
|
|
74
|
+
active: 0,
|
|
75
|
+
idle: 0,
|
|
76
|
+
waitQueueSize: 0,
|
|
77
|
+
lastUpdated: null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Circuit breaker state changes: Array<{service, state, timestamp}>
|
|
82
|
+
this.circuitBreakerStates = [];
|
|
83
|
+
|
|
47
84
|
// Error counter for unhandled exceptions
|
|
48
85
|
this.unhandledErrors = 0;
|
|
49
86
|
}
|
|
@@ -131,11 +168,162 @@ export class MetricsAggregator {
|
|
|
131
168
|
this.unhandledErrors++;
|
|
132
169
|
}
|
|
133
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Record a MongoDB operation
|
|
173
|
+
*
|
|
174
|
+
* @param {string} collection - Collection name
|
|
175
|
+
* @param {string} operation - Operation type (find, insert, update, delete, aggregate)
|
|
176
|
+
* @param {number} latency - Response time in milliseconds
|
|
177
|
+
* @param {boolean} isError - Whether the operation resulted in an error
|
|
178
|
+
*/
|
|
179
|
+
recordMongoOperation(collection, operation, latency, isError = false) {
|
|
180
|
+
const key = createMongoKey(collection, operation);
|
|
181
|
+
|
|
182
|
+
let metric = this.mongodbOperations.get(key);
|
|
183
|
+
if (!metric) {
|
|
184
|
+
metric = {
|
|
185
|
+
collection,
|
|
186
|
+
operation,
|
|
187
|
+
count: 0,
|
|
188
|
+
errorCount: 0,
|
|
189
|
+
latency: {
|
|
190
|
+
sum: 0,
|
|
191
|
+
min: Infinity,
|
|
192
|
+
max: 0
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
this.mongodbOperations.set(key, metric);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
metric.count++;
|
|
199
|
+
if (isError) {
|
|
200
|
+
metric.errorCount++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
metric.latency.sum += latency;
|
|
204
|
+
metric.latency.min = Math.min(metric.latency.min, latency);
|
|
205
|
+
metric.latency.max = Math.max(metric.latency.max, latency);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Record a slow MongoDB query
|
|
210
|
+
*
|
|
211
|
+
* @param {string} collection - Collection name
|
|
212
|
+
* @param {string} operation - Operation type
|
|
213
|
+
* @param {number} latency - Response time in milliseconds
|
|
214
|
+
*/
|
|
215
|
+
recordSlowQuery(collection, operation, latency) {
|
|
216
|
+
// Keep only the most recent slow queries (max 100 per interval)
|
|
217
|
+
if (this.slowQueries.length >= 100) {
|
|
218
|
+
this.slowQueries.shift();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.slowQueries.push({
|
|
222
|
+
collection,
|
|
223
|
+
operation,
|
|
224
|
+
latency: Math.round(latency),
|
|
225
|
+
timestamp: Date.now()
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Record an OpenAI API operation
|
|
231
|
+
*
|
|
232
|
+
* @param {Object} data - Operation data
|
|
233
|
+
* @param {string} data.operation - Operation type (chat.completions, embeddings, images.generate, etc.)
|
|
234
|
+
* @param {string} data.model - Model used (gpt-4, gpt-3.5-turbo, etc.)
|
|
235
|
+
* @param {number} data.latency - Response time in milliseconds
|
|
236
|
+
* @param {number} data.promptTokens - Number of prompt tokens used
|
|
237
|
+
* @param {number} data.completionTokens - Number of completion tokens used
|
|
238
|
+
* @param {number} data.totalTokens - Total tokens used
|
|
239
|
+
* @param {number} data.cost - Estimated cost in USD
|
|
240
|
+
* @param {string|null} data.error - Error message if failed
|
|
241
|
+
* @param {number} data.statusCode - HTTP status code
|
|
242
|
+
*/
|
|
243
|
+
recordOpenAIOperation(data) {
|
|
244
|
+
const key = createOpenAIKey(data.operation, data.model);
|
|
245
|
+
|
|
246
|
+
let metric = this.openaiOperations.get(key);
|
|
247
|
+
if (!metric) {
|
|
248
|
+
metric = {
|
|
249
|
+
operation: data.operation,
|
|
250
|
+
model: data.model,
|
|
251
|
+
count: 0,
|
|
252
|
+
errorCount: 0,
|
|
253
|
+
latency: {
|
|
254
|
+
sum: 0,
|
|
255
|
+
min: Infinity,
|
|
256
|
+
max: 0
|
|
257
|
+
},
|
|
258
|
+
tokens: {
|
|
259
|
+
prompt: 0,
|
|
260
|
+
completion: 0,
|
|
261
|
+
total: 0
|
|
262
|
+
},
|
|
263
|
+
cost: 0
|
|
264
|
+
};
|
|
265
|
+
this.openaiOperations.set(key, metric);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
metric.count++;
|
|
269
|
+
if (data.error) {
|
|
270
|
+
metric.errorCount++;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
metric.latency.sum += data.latency;
|
|
274
|
+
metric.latency.min = Math.min(metric.latency.min, data.latency);
|
|
275
|
+
metric.latency.max = Math.max(metric.latency.max, data.latency);
|
|
276
|
+
|
|
277
|
+
metric.tokens.prompt += data.promptTokens || 0;
|
|
278
|
+
metric.tokens.completion += data.completionTokens || 0;
|
|
279
|
+
metric.tokens.total += data.totalTokens || 0;
|
|
280
|
+
|
|
281
|
+
metric.cost += data.cost || 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Update MongoDB connection pool statistics
|
|
286
|
+
*
|
|
287
|
+
* @param {Object} stats - Pool statistics
|
|
288
|
+
* @param {number} stats.active - Active connections (checked out)
|
|
289
|
+
* @param {number} stats.idle - Idle connections (available in pool)
|
|
290
|
+
* @param {number} stats.waitQueueSize - Wait queue size
|
|
291
|
+
* @param {number} stats.total - Total connections in pool
|
|
292
|
+
*/
|
|
293
|
+
updatePoolStats(stats) {
|
|
294
|
+
this.mongodbPoolStats = {
|
|
295
|
+
active: stats.active || 0,
|
|
296
|
+
idle: stats.idle || 0,
|
|
297
|
+
waitQueueSize: stats.waitQueueSize || 0,
|
|
298
|
+
total: stats.total || (stats.active || 0) + (stats.idle || 0),
|
|
299
|
+
lastUpdated: Date.now()
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Record a circuit breaker state change
|
|
305
|
+
*
|
|
306
|
+
* @param {string} service - Service/dependency name (e.g., 'mongodb')
|
|
307
|
+
* @param {string} state - New state ('open', 'halfOpen', 'close')
|
|
308
|
+
*/
|
|
309
|
+
recordCircuitState(service, state) {
|
|
310
|
+
this.circuitBreakerStates.push({
|
|
311
|
+
service,
|
|
312
|
+
state,
|
|
313
|
+
timestamp: Date.now()
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
134
317
|
/**
|
|
135
318
|
* Check if there's data to flush
|
|
136
319
|
*/
|
|
137
320
|
hasData() {
|
|
138
|
-
return this.requests.size > 0 ||
|
|
321
|
+
return this.requests.size > 0 ||
|
|
322
|
+
this.dependencies.size > 0 ||
|
|
323
|
+
this.mongodbOperations.size > 0 ||
|
|
324
|
+
this.slowQueries.length > 0 ||
|
|
325
|
+
this.circuitBreakerStates.length > 0 ||
|
|
326
|
+
this.openaiOperations.size > 0;
|
|
139
327
|
}
|
|
140
328
|
|
|
141
329
|
/**
|
|
@@ -148,7 +336,12 @@ export class MetricsAggregator {
|
|
|
148
336
|
service: config.service,
|
|
149
337
|
environment: config.environment,
|
|
150
338
|
requests: [],
|
|
151
|
-
dependencies: []
|
|
339
|
+
dependencies: [],
|
|
340
|
+
mongodb: [],
|
|
341
|
+
mongodbPool: null,
|
|
342
|
+
slowQueries: [],
|
|
343
|
+
circuitBreaker: [],
|
|
344
|
+
openai: []
|
|
152
345
|
};
|
|
153
346
|
|
|
154
347
|
// Convert request metrics to array
|
|
@@ -181,6 +374,53 @@ export class MetricsAggregator {
|
|
|
181
374
|
});
|
|
182
375
|
}
|
|
183
376
|
|
|
377
|
+
// Convert MongoDB operation metrics to array
|
|
378
|
+
for (const metric of this.mongodbOperations.values()) {
|
|
379
|
+
payload.mongodb.push({
|
|
380
|
+
collection: metric.collection,
|
|
381
|
+
operation: metric.operation,
|
|
382
|
+
count: metric.count,
|
|
383
|
+
errorCount: metric.errorCount,
|
|
384
|
+
latency: {
|
|
385
|
+
sum: Math.round(metric.latency.sum),
|
|
386
|
+
min: metric.latency.min === Infinity ? 0 : Math.round(metric.latency.min),
|
|
387
|
+
max: Math.round(metric.latency.max)
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Include MongoDB pool stats if available
|
|
393
|
+
if (this.mongodbPoolStats && this.mongodbPoolStats.lastUpdated) {
|
|
394
|
+
payload.mongodbPool = { ...this.mongodbPoolStats };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Include slow queries
|
|
398
|
+
payload.slowQueries = [...this.slowQueries];
|
|
399
|
+
|
|
400
|
+
// Include circuit breaker state changes
|
|
401
|
+
payload.circuitBreaker = [...this.circuitBreakerStates];
|
|
402
|
+
|
|
403
|
+
// Convert OpenAI operation metrics to array
|
|
404
|
+
for (const metric of this.openaiOperations.values()) {
|
|
405
|
+
payload.openai.push({
|
|
406
|
+
operation: metric.operation,
|
|
407
|
+
model: metric.model,
|
|
408
|
+
count: metric.count,
|
|
409
|
+
errorCount: metric.errorCount,
|
|
410
|
+
latency: {
|
|
411
|
+
sum: Math.round(metric.latency.sum),
|
|
412
|
+
min: metric.latency.min === Infinity ? 0 : Math.round(metric.latency.min),
|
|
413
|
+
max: Math.round(metric.latency.max)
|
|
414
|
+
},
|
|
415
|
+
tokens: {
|
|
416
|
+
prompt: metric.tokens.prompt,
|
|
417
|
+
completion: metric.tokens.completion,
|
|
418
|
+
total: metric.tokens.total
|
|
419
|
+
},
|
|
420
|
+
cost: Math.round(metric.cost * 1000000) / 1000000 // 6 decimal places
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
184
424
|
// Reset for next interval
|
|
185
425
|
this.reset();
|
|
186
426
|
|
|
@@ -194,6 +434,10 @@ export class MetricsAggregator {
|
|
|
194
434
|
return {
|
|
195
435
|
requestMetrics: this.requests.size,
|
|
196
436
|
dependencyMetrics: this.dependencies.size,
|
|
437
|
+
mongodbMetrics: this.mongodbOperations.size,
|
|
438
|
+
openaiMetrics: this.openaiOperations.size,
|
|
439
|
+
slowQueries: this.slowQueries.length,
|
|
440
|
+
circuitBreakerEvents: this.circuitBreakerStates.length,
|
|
197
441
|
uniqueRoutes: this.routeRegistry.size,
|
|
198
442
|
unhandledErrors: this.unhandledErrors
|
|
199
443
|
};
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SentienGuard APM SDK - Browser No-Op
|
|
3
|
+
*
|
|
4
|
+
* This module is automatically used when the SDK is imported in a browser
|
|
5
|
+
* environment (via bundlers like Webpack, Vite, Rsbuild, etc.).
|
|
6
|
+
*
|
|
7
|
+
* The APM SDK only works in Node.js (it patches http/https modules),
|
|
8
|
+
* so in the browser we export no-op stubs to avoid build errors.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
const asyncNoop = async () => {};
|
|
13
|
+
const noopMiddleware = (_req, _res, next) => next?.();
|
|
14
|
+
|
|
15
|
+
function initialize() {}
|
|
16
|
+
async function shutdown() {}
|
|
17
|
+
function getStatus() {
|
|
18
|
+
return {
|
|
19
|
+
enabled: false,
|
|
20
|
+
initialized: false,
|
|
21
|
+
config: { service: '', environment: '', flushInterval: 0 },
|
|
22
|
+
stats: {}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function flush() {}
|
|
26
|
+
function getConfig() {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
function isEnabled() {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function normalizeRoute(route) {
|
|
33
|
+
return route;
|
|
34
|
+
}
|
|
35
|
+
function extractRoute(req) {
|
|
36
|
+
return req?.url || '/';
|
|
37
|
+
}
|
|
38
|
+
function getAggregator() {
|
|
39
|
+
return {
|
|
40
|
+
getStats: () => ({}),
|
|
41
|
+
recordRequest: noop,
|
|
42
|
+
recordDependency: noop,
|
|
43
|
+
recordError: noop
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function instrumentMongoDB() {}
|
|
47
|
+
function instrumentOpenAI() {}
|
|
48
|
+
function createBreaker(fn) {
|
|
49
|
+
return fn;
|
|
50
|
+
}
|
|
51
|
+
function wrapMongoOperation(fn) {
|
|
52
|
+
return fn;
|
|
53
|
+
}
|
|
54
|
+
function getBreakerStats() {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const RouteRegistry = {
|
|
59
|
+
register: noop,
|
|
60
|
+
match: () => null
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const expressMiddleware = noopMiddleware;
|
|
64
|
+
const expressErrorMiddleware = (_err, _req, _res, next) => next?.();
|
|
65
|
+
const fastifyPlugin = noop;
|
|
66
|
+
const fastifyErrorHandler = noop;
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
initialize,
|
|
70
|
+
shutdown,
|
|
71
|
+
getStatus,
|
|
72
|
+
flush,
|
|
73
|
+
getConfig,
|
|
74
|
+
isEnabled,
|
|
75
|
+
expressMiddleware,
|
|
76
|
+
expressErrorMiddleware,
|
|
77
|
+
fastifyPlugin,
|
|
78
|
+
fastifyErrorHandler,
|
|
79
|
+
normalizeRoute,
|
|
80
|
+
extractRoute,
|
|
81
|
+
RouteRegistry,
|
|
82
|
+
getAggregator,
|
|
83
|
+
instrumentMongoDB,
|
|
84
|
+
instrumentOpenAI,
|
|
85
|
+
createBreaker,
|
|
86
|
+
wrapMongoOperation,
|
|
87
|
+
getBreakerStats
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default {
|
|
91
|
+
initialize,
|
|
92
|
+
shutdown,
|
|
93
|
+
getStatus,
|
|
94
|
+
flush,
|
|
95
|
+
expressMiddleware,
|
|
96
|
+
expressErrorMiddleware,
|
|
97
|
+
fastifyPlugin,
|
|
98
|
+
fastifyErrorHandler,
|
|
99
|
+
normalizeRoute,
|
|
100
|
+
extractRoute,
|
|
101
|
+
getAggregator,
|
|
102
|
+
instrumentMongoDB,
|
|
103
|
+
instrumentOpenAI,
|
|
104
|
+
createBreaker,
|
|
105
|
+
wrapMongoOperation,
|
|
106
|
+
getBreakerStats
|
|
107
|
+
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker Module
|
|
3
|
+
*
|
|
4
|
+
* Provides circuit breaker functionality using Opossum library.
|
|
5
|
+
* Wraps operations (especially database calls) to prevent cascade failures.
|
|
6
|
+
*
|
|
7
|
+
* Circuit States:
|
|
8
|
+
* - CLOSED: Normal operation, requests pass through
|
|
9
|
+
* - OPEN: Circuit tripped due to failures, requests fail fast
|
|
10
|
+
* - HALF-OPEN: Testing if service recovered, limited requests allowed
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { createBreaker, wrapMongoOperation } from './circuitBreaker.js';
|
|
14
|
+
*
|
|
15
|
+
* // Wrap an async operation
|
|
16
|
+
* const breaker = createBreaker(async () => await db.query(), { name: 'db-query' });
|
|
17
|
+
* const result = await breaker.fire();
|
|
18
|
+
*
|
|
19
|
+
* // Or use the MongoDB helper
|
|
20
|
+
* const findUsers = wrapMongoOperation('User.find', async () => User.find());
|
|
21
|
+
* const users = await findUsers();
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import config, { debug, warn } from './config.js';
|
|
25
|
+
import { getAggregator } from './aggregator.js';
|
|
26
|
+
|
|
27
|
+
// Store active circuit breakers
|
|
28
|
+
const breakers = new Map();
|
|
29
|
+
|
|
30
|
+
// Check if opossum is available
|
|
31
|
+
let CircuitBreaker = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialize Opossum (lazy load)
|
|
35
|
+
*/
|
|
36
|
+
async function initOpossum() {
|
|
37
|
+
if (CircuitBreaker) return true;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const opossum = await import('opossum');
|
|
41
|
+
CircuitBreaker = opossum.default || opossum;
|
|
42
|
+
debug('Opossum circuit breaker loaded');
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
debug('Opossum not installed. Circuit breaker disabled. Install with: npm install opossum');
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default circuit breaker options
|
|
52
|
+
*/
|
|
53
|
+
function getDefaultOptions() {
|
|
54
|
+
return {
|
|
55
|
+
timeout: config.circuitBreaker.timeout,
|
|
56
|
+
errorThresholdPercentage: config.circuitBreaker.errorThresholdPercentage,
|
|
57
|
+
resetTimeout: config.circuitBreaker.resetTimeout,
|
|
58
|
+
volumeThreshold: config.circuitBreaker.volumeThreshold
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a circuit breaker for an async operation
|
|
64
|
+
*
|
|
65
|
+
* @param {Function} asyncFn - The async function to wrap
|
|
66
|
+
* @param {Object} options - Circuit breaker options
|
|
67
|
+
* @param {string} options.name - Name for the circuit (used for metrics)
|
|
68
|
+
* @param {number} options.timeout - Timeout in ms
|
|
69
|
+
* @param {number} options.errorThresholdPercentage - Error threshold to trip
|
|
70
|
+
* @param {number} options.resetTimeout - Time before attempting recovery
|
|
71
|
+
* @param {Function} options.fallback - Fallback function when circuit is open
|
|
72
|
+
* @returns {Object} Circuit breaker instance or wrapped function if opossum unavailable
|
|
73
|
+
*/
|
|
74
|
+
export async function createBreaker(asyncFn, options = {}) {
|
|
75
|
+
if (!config.circuitBreaker.enabled) {
|
|
76
|
+
debug('Circuit breaker disabled, returning passthrough');
|
|
77
|
+
return createPassthrough(asyncFn);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const initialized = await initOpossum();
|
|
81
|
+
if (!initialized) {
|
|
82
|
+
return createPassthrough(asyncFn);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const name = options.name || 'unnamed';
|
|
86
|
+
const breakerOptions = {
|
|
87
|
+
...getDefaultOptions(),
|
|
88
|
+
...options,
|
|
89
|
+
name
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const breaker = new CircuitBreaker(asyncFn, breakerOptions);
|
|
93
|
+
const aggregator = getAggregator();
|
|
94
|
+
|
|
95
|
+
// Attach event listeners for metrics
|
|
96
|
+
breaker.on('open', () => {
|
|
97
|
+
aggregator.recordCircuitState(name, 'open');
|
|
98
|
+
warn(`Circuit breaker OPEN: ${name}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
breaker.on('halfOpen', () => {
|
|
102
|
+
aggregator.recordCircuitState(name, 'halfOpen');
|
|
103
|
+
debug(`Circuit breaker HALF-OPEN: ${name}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
breaker.on('close', () => {
|
|
107
|
+
aggregator.recordCircuitState(name, 'close');
|
|
108
|
+
debug(`Circuit breaker CLOSED: ${name}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
breaker.on('fallback', (result) => {
|
|
112
|
+
debug(`Circuit breaker fallback executed: ${name}`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
breaker.on('timeout', () => {
|
|
116
|
+
debug(`Circuit breaker timeout: ${name}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
breaker.on('reject', () => {
|
|
120
|
+
debug(`Circuit breaker rejected (circuit open): ${name}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Register fallback if provided
|
|
124
|
+
if (options.fallback) {
|
|
125
|
+
breaker.fallback(options.fallback);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Store reference
|
|
129
|
+
breakers.set(name, breaker);
|
|
130
|
+
|
|
131
|
+
return breaker;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a passthrough wrapper when circuit breaker is disabled
|
|
136
|
+
*/
|
|
137
|
+
function createPassthrough(asyncFn) {
|
|
138
|
+
return {
|
|
139
|
+
fire: (...args) => asyncFn(...args),
|
|
140
|
+
fallback: () => {},
|
|
141
|
+
on: () => {},
|
|
142
|
+
isOpen: () => false,
|
|
143
|
+
isClosed: () => true,
|
|
144
|
+
stats: { fires: 0, failures: 0, successes: 0 }
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a circuit breaker specifically for MongoDB operations
|
|
150
|
+
*
|
|
151
|
+
* @param {string} operationName - Name of the operation (e.g., 'User.find')
|
|
152
|
+
* @param {Function} mongoOperation - The MongoDB operation function
|
|
153
|
+
* @param {Object} options - Additional options
|
|
154
|
+
* @returns {Function} Wrapped function that uses circuit breaker
|
|
155
|
+
*/
|
|
156
|
+
export async function wrapMongoOperation(operationName, mongoOperation, options = {}) {
|
|
157
|
+
const breaker = await createBreaker(mongoOperation, {
|
|
158
|
+
name: `mongodb:${operationName}`,
|
|
159
|
+
// MongoDB-specific defaults
|
|
160
|
+
timeout: options.timeout || config.circuitBreaker.timeout,
|
|
161
|
+
errorThresholdPercentage: options.errorThresholdPercentage || 50,
|
|
162
|
+
resetTimeout: options.resetTimeout || 30000,
|
|
163
|
+
...options
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Return a function that fires the breaker
|
|
167
|
+
return async function wrappedOperation(...args) {
|
|
168
|
+
return breaker.fire(...args);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get a registered circuit breaker by name
|
|
174
|
+
*
|
|
175
|
+
* @param {string} name - Circuit breaker name
|
|
176
|
+
* @returns {Object|null} Circuit breaker instance or null
|
|
177
|
+
*/
|
|
178
|
+
export function getBreaker(name) {
|
|
179
|
+
return breakers.get(name) || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get all registered circuit breakers
|
|
184
|
+
*
|
|
185
|
+
* @returns {Map} Map of circuit breaker instances
|
|
186
|
+
*/
|
|
187
|
+
export function getAllBreakers() {
|
|
188
|
+
return new Map(breakers);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get circuit breaker statistics
|
|
193
|
+
*
|
|
194
|
+
* @param {string} name - Circuit breaker name (optional, returns all if not provided)
|
|
195
|
+
* @returns {Object} Statistics object
|
|
196
|
+
*/
|
|
197
|
+
export function getBreakerStats(name) {
|
|
198
|
+
if (name) {
|
|
199
|
+
const breaker = breakers.get(name);
|
|
200
|
+
if (!breaker) return null;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
name,
|
|
204
|
+
state: breaker.opened ? 'open' : (breaker.halfOpen ? 'halfOpen' : 'closed'),
|
|
205
|
+
stats: breaker.stats
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Return stats for all breakers
|
|
210
|
+
const allStats = {};
|
|
211
|
+
for (const [breakerName, breaker] of breakers) {
|
|
212
|
+
allStats[breakerName] = {
|
|
213
|
+
state: breaker.opened ? 'open' : (breaker.halfOpen ? 'halfOpen' : 'closed'),
|
|
214
|
+
stats: breaker.stats
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return allStats;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reset a circuit breaker (close it)
|
|
222
|
+
*
|
|
223
|
+
* @param {string} name - Circuit breaker name
|
|
224
|
+
*/
|
|
225
|
+
export function resetBreaker(name) {
|
|
226
|
+
const breaker = breakers.get(name);
|
|
227
|
+
if (breaker && typeof breaker.close === 'function') {
|
|
228
|
+
breaker.close();
|
|
229
|
+
debug(`Circuit breaker reset: ${name}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Shutdown all circuit breakers
|
|
235
|
+
*/
|
|
236
|
+
export function shutdownBreakers() {
|
|
237
|
+
for (const [name, breaker] of breakers) {
|
|
238
|
+
if (typeof breaker.shutdown === 'function') {
|
|
239
|
+
breaker.shutdown();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
breakers.clear();
|
|
243
|
+
debug('All circuit breakers shutdown');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export default {
|
|
247
|
+
createBreaker,
|
|
248
|
+
wrapMongoOperation,
|
|
249
|
+
getBreaker,
|
|
250
|
+
getAllBreakers,
|
|
251
|
+
getBreakerStats,
|
|
252
|
+
resetBreaker,
|
|
253
|
+
shutdownBreakers
|
|
254
|
+
};
|