@rodit/rodit-auth-be 9.11.14

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.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Performance monitoring service for tracing and metrics collection
3
+ * Copyright (c) 2026 Discernible IO. All rights reserved.
4
+ */
5
+
6
+ const { ulid } = require('ulid');
7
+ const logger = require('./logger');
8
+ const os = require('os');
9
+
10
+ const RPM_WINDOW_SEC = 60;
11
+ const MAX_ENDPOINT_KEYS = 80;
12
+ const MAX_DURATION_SAMPLES = 200;
13
+ const OTHER_KEY = '*';
14
+
15
+ class PerformanceService {
16
+ constructor() {
17
+ this.traces = new Map();
18
+ this._processStartedAt = Date.now();
19
+ this._lastResetAt = null;
20
+ /** @type {Map<number, number>} unix second -> request count */
21
+ this._requestsBySecond = new Map();
22
+ /** @type {Map<string, object>} */
23
+ this._endpointStats = new Map();
24
+ this.metrics = {
25
+ requestCount: 0,
26
+ errorCount: 0,
27
+ totalDuration: 0,
28
+ maxDuration: 0,
29
+ minDuration: Number.MAX_SAFE_INTEGER,
30
+ blockchainCalls: 0,
31
+ blockchainDuration: 0,
32
+ authenticationCalls: 0,
33
+ authenticationDuration: 0
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Normalize URL path for cardinality control (group numeric / id-like segments).
39
+ * @param {string} rawUrl
40
+ */
41
+ static normalizeEndpointKey(rawUrl) {
42
+ try {
43
+ const pathname = rawUrl.includes("://")
44
+ ? new URL(rawUrl).pathname
45
+ : rawUrl.split("?")[0];
46
+ return pathname
47
+ .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "/:uuid")
48
+ .replace(/\/[0-9a-hjkmnp-tv-z]{26}\b/gi, "/:ulid")
49
+ .replace(/\/\d+/g, "/:id");
50
+ } catch {
51
+ return String(rawUrl).split("?")[0];
52
+ }
53
+ }
54
+
55
+ _pruneRequestBuckets(nowSec) {
56
+ const cutoff = nowSec - RPM_WINDOW_SEC - 5;
57
+ for (const sec of this._requestsBySecond.keys()) {
58
+ if (sec < cutoff) {
59
+ this._requestsBySecond.delete(sec);
60
+ }
61
+ }
62
+ }
63
+
64
+ _incrementRpmWindow() {
65
+ const nowSec = Math.floor(Date.now() / 1000);
66
+ this._pruneRequestBuckets(nowSec);
67
+ this._requestsBySecond.set(nowSec, (this._requestsBySecond.get(nowSec) || 0) + 1);
68
+ }
69
+
70
+ _computeRequestsPerMinute() {
71
+ const nowSec = Math.floor(Date.now() / 1000);
72
+ this._pruneRequestBuckets(nowSec);
73
+ let sum = 0;
74
+ for (let s = nowSec - RPM_WINDOW_SEC + 1; s <= nowSec; s++) {
75
+ sum += this._requestsBySecond.get(s) || 0;
76
+ }
77
+ return sum;
78
+ }
79
+
80
+ _percentile(sorted, p) {
81
+ if (!sorted.length) {
82
+ return null;
83
+ }
84
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
85
+ return sorted[Math.max(0, Math.min(sorted.length - 1, idx))];
86
+ }
87
+
88
+ _computeLoadLevel(rpm, requestCount, errorCount) {
89
+ const errRate = requestCount > 0 ? errorCount / requestCount : 0;
90
+ if (errRate > 0.15) {
91
+ return "high";
92
+ }
93
+ if (rpm > 600 || errRate > 0.05) {
94
+ return "high";
95
+ }
96
+ if (rpm > 120 || errRate > 0.01) {
97
+ return "medium";
98
+ }
99
+ return "low";
100
+ }
101
+
102
+ _serializeEndpointMetrics() {
103
+ const out = {};
104
+ for (const [key, st] of this._endpointStats.entries()) {
105
+ const samples = [...st.durations].sort((a, b) => a - b);
106
+ const count = st.count;
107
+ const errCount = st.errorCount;
108
+ out[key] = {
109
+ count,
110
+ errorCount: errCount,
111
+ totalDurationMs: st.totalDurationMs,
112
+ avgMs: count ? Math.round(st.totalDurationMs / count) : 0,
113
+ minMs: st.minMs === Number.MAX_SAFE_INTEGER ? null : st.minMs,
114
+ maxMs: st.maxMs === 0 ? null : st.maxMs,
115
+ p50Ms: this._percentile(samples, 50),
116
+ p95Ms: this._percentile(samples, 95),
117
+ p99Ms: this._percentile(samples, 99)
118
+ };
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Initialize the performance monitoring service
125
+ *
126
+ */
127
+ initialize() {
128
+ logger.info('Performance monitoring service initialized', {
129
+ component: 'PerformanceService',
130
+ method: 'initialize'
131
+ });
132
+
133
+ return this;
134
+ }
135
+
136
+ /**
137
+ * Record a new request
138
+ *
139
+ * @param {Object} req - Express request object
140
+ */
141
+ recordRequest(req) {
142
+ // Update total request count metric
143
+ this.metrics.requestCount++;
144
+ this._incrementRpmWindow();
145
+
146
+ logger.debug('Request recorded', {
147
+ component: 'PerformanceService',
148
+ method: 'recordRequest',
149
+ path: req.path,
150
+ requestMethod: req.method
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Record a metric
156
+ * Uses the standardized logger.metric method for consistent metric collection
157
+ * while also updating internal state for load monitoring
158
+ *
159
+ * @param {string} metricName - Name of the metric
160
+ * @param {number} value - Value to record
161
+ * @param {Object} tags - Additional tags for the metric
162
+ */
163
+ recordMetric(metricName, value, tags = {}) {
164
+ // Always use the standardized logger.metric method for metrics
165
+ logger.metric(metricName, value, {
166
+ ...tags,
167
+ component: 'PerformanceService'
168
+ });
169
+
170
+ // Update internal metrics for load monitoring and reporting
171
+ switch(metricName) {
172
+ case 'request_count':
173
+ case 'http_request_duration_ms':
174
+ this.metrics.requestCount += (metricName === 'request_count' ? value : 1);
175
+ break;
176
+ case 'error_count':
177
+ case 'http_errors_total':
178
+ this.metrics.errorCount += value;
179
+ break;
180
+ case 'authentication_duration':
181
+ case 'authentication_duration_ms':
182
+ this.metrics.authenticationCalls++;
183
+ this.metrics.authenticationDuration += value;
184
+ break;
185
+ case 'blockchain_duration':
186
+ case 'blockchain_duration_ms':
187
+ this.metrics.blockchainCalls++;
188
+ this.metrics.blockchainDuration += value;
189
+ break;
190
+ case 'authentication_error':
191
+ case 'blockchain_error':
192
+ this.metrics.errorCount += value;
193
+ break;
194
+ case 'request_duration':
195
+ this.metrics.totalDuration += value;
196
+ this.metrics.maxDuration = Math.max(this.metrics.maxDuration, value);
197
+ this.metrics.minDuration = Math.min(this.metrics.minDuration, value);
198
+ break;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Per-endpoint latency and error stats (in-process, bounded cardinality).
204
+ *
205
+ * @param {string} method
206
+ * @param {string} url
207
+ * @param {number} durationMs
208
+ * @param {number} statusCode
209
+ */
210
+ recordEndpointMetric(method, url, durationMs, statusCode) {
211
+ const path = PerformanceService.normalizeEndpointKey(url);
212
+ let key = `${method} ${path}`;
213
+ if (this._endpointStats.size >= MAX_ENDPOINT_KEYS && !this._endpointStats.has(key)) {
214
+ key = `${method} ${OTHER_KEY}`;
215
+ }
216
+ let st = this._endpointStats.get(key);
217
+ if (!st) {
218
+ st = {
219
+ count: 0,
220
+ errorCount: 0,
221
+ totalDurationMs: 0,
222
+ minMs: Number.MAX_SAFE_INTEGER,
223
+ maxMs: 0,
224
+ durations: []
225
+ };
226
+ this._endpointStats.set(key, st);
227
+ }
228
+ st.count++;
229
+ if (statusCode >= 400) {
230
+ st.errorCount++;
231
+ }
232
+ st.totalDurationMs += durationMs;
233
+ st.minMs = Math.min(st.minMs, durationMs);
234
+ st.maxMs = Math.max(st.maxMs, durationMs);
235
+ if (st.durations.length < MAX_DURATION_SAMPLES) {
236
+ st.durations.push(durationMs);
237
+ } else {
238
+ const i = Math.floor(Math.random() * st.durations.length);
239
+ st.durations[i] = durationMs;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Start a trace for performance monitoring
245
+ *
246
+ * @param {string} operationName - Name of the operation being traced
247
+ * @param {Object} metadata - Additional metadata for the trace
248
+ * @returns {string} Trace ID
249
+ */
250
+ startTrace(operationName, metadata = {}) {
251
+ const traceId = metadata.traceId || ulid();
252
+ const startTime = Date.now();
253
+
254
+ this.traces.set(traceId, {
255
+ id: traceId,
256
+ operation: operationName,
257
+ startTime,
258
+ metadata,
259
+ spans: [],
260
+ completed: false
261
+ });
262
+
263
+ // Log trace start as a metric
264
+ logger.metric('trace_started_total', 1, {
265
+ operation: operationName,
266
+ component: 'PerformanceService',
267
+ request_id: metadata.requestId
268
+ });
269
+
270
+ logger.debug(`Started trace for ${operationName}`, {
271
+ component: 'PerformanceService',
272
+ method: 'startTrace',
273
+ traceId,
274
+ operation: operationName,
275
+ metadata: JSON.stringify(metadata)
276
+ });
277
+
278
+ return traceId;
279
+ }
280
+
281
+ /**
282
+ * Add a span to an existing trace
283
+ *
284
+ * @param {string} traceId - ID of the parent trace
285
+ * @param {string} spanName - Name of the span
286
+ * @param {Object} metadata - Additional metadata for the span
287
+ * @returns {Object} Span object with stop function
288
+ */
289
+ startSpan(traceId, spanName, metadata = {}) {
290
+ const trace = this.traces.get(traceId);
291
+
292
+ if (!trace) {
293
+ logger.warn('Attempted to add span to non-existent trace', {
294
+ component: 'PerformanceService',
295
+ method: 'startSpan',
296
+ traceId,
297
+ spanName
298
+ });
299
+
300
+ return {
301
+ id: ulid(),
302
+ stop: () => {}
303
+ };
304
+ }
305
+
306
+ const spanId = ulid();
307
+ const span = {
308
+ id: spanId,
309
+ name: spanName,
310
+ startTime: Date.now(),
311
+ metadata: { ...metadata },
312
+ parentId: traceId
313
+ };
314
+
315
+ trace.spans.push(span);
316
+
317
+ logger.debug('Span started', {
318
+ component: 'PerformanceService',
319
+ method: 'startSpan',
320
+ traceId,
321
+ spanId,
322
+ spanName
323
+ });
324
+
325
+ return {
326
+ id: spanId,
327
+ stop: () => this.stopSpan(traceId, spanId)
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Stop a span and record its duration
333
+ *
334
+ * @param {string} traceId - ID of the parent trace
335
+ * @param {string} spanId - ID of the span to stop
336
+ */
337
+ stopSpan(traceId, spanId) {
338
+ const trace = this.traces.get(traceId);
339
+
340
+ if (!trace) {
341
+ return;
342
+ }
343
+
344
+ const span = trace.spans.find(s => s.id === spanId);
345
+
346
+ if (!span) {
347
+ return;
348
+ }
349
+
350
+ span.endTime = Date.now();
351
+ span.duration = span.endTime - span.startTime;
352
+
353
+ // Track specific metrics based on span class
354
+ if (span.name.includes('blockchain')) {
355
+ this.metrics.blockchainCalls++;
356
+ this.metrics.blockchainDuration += span.duration;
357
+ } else if (span.name.includes('auth')) {
358
+ this.metrics.authenticationCalls++;
359
+ this.metrics.authenticationDuration += span.duration;
360
+ }
361
+
362
+ const logLevel = this._getDurationLogLevel(span.duration);
363
+
364
+ logger[logLevel]('Span completed', {
365
+ component: 'PerformanceService',
366
+ method: 'stopSpan',
367
+ traceId,
368
+ spanId,
369
+ spanName: span.name,
370
+ duration: span.duration
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Complete a trace with results
376
+ *
377
+ * @param {string} traceId - ID of the trace to complete
378
+ * @param {Object} results - Results of the operation
379
+ * @returns {boolean} Whether the trace was successfully completed
380
+ */
381
+ completeTrace(traceId, results = {}) {
382
+ if (!this.traces.has(traceId)) {
383
+ logger.warn(`Attempted to complete unknown trace: ${traceId}`, {
384
+ component: 'PerformanceService',
385
+ method: 'completeTrace'
386
+ });
387
+ return false;
388
+ }
389
+
390
+ const trace = this.traces.get(traceId);
391
+ if (trace.completed) {
392
+ logger.warn(`Attempted to complete already completed trace: ${traceId}`, {
393
+ component: 'PerformanceService',
394
+ method: 'completeTrace'
395
+ });
396
+ return false;
397
+ }
398
+
399
+ const endTime = Date.now();
400
+ const duration = endTime - trace.startTime;
401
+
402
+ // Update the trace with completion info
403
+ trace.completed = true;
404
+ trace.endTime = endTime;
405
+ trace.duration = duration;
406
+ trace.results = results;
407
+
408
+ // Log trace completion as a metric
409
+ logger.metric('trace_duration_ms', duration, {
410
+ operation: trace.operation,
411
+ component: 'PerformanceService',
412
+ status: results.success !== false ? 'success' : 'failure',
413
+ error: results.error ? 'true' : 'false',
414
+ status_code: results.statusCode || 0
415
+ });
416
+
417
+ // If there was an error, log an error metric
418
+ if (results.error) {
419
+ logger.metric('trace_errors_total', 1, {
420
+ operation: trace.operation,
421
+ component: 'PerformanceService',
422
+ error_type: typeof results.error === 'string' ? results.error : 'unknown'
423
+ });
424
+ }
425
+
426
+ logger.debug(`Completed trace for ${trace.operation}`, {
427
+ component: 'PerformanceService',
428
+ method: 'completeTrace',
429
+ traceId,
430
+ operation: trace.operation,
431
+ duration,
432
+ success: results.success !== false,
433
+ error: results.error,
434
+ metadata: trace.metadata ? JSON.stringify(trace.metadata) : null
435
+ });
436
+
437
+ return true;
438
+ }
439
+
440
+ /**
441
+ * End a trace (alias for completeTrace)
442
+ *
443
+ * @param {string} traceId - ID of the trace to end
444
+ * @param {Object} result - Result of the operation
445
+ * @returns {Object} Completed trace with metrics
446
+ */
447
+ endTrace(traceId, result = {}) {
448
+ return this.completeTrace(traceId, result);
449
+ }
450
+
451
+ /**
452
+ * Get a trace by ID
453
+ *
454
+ * @param {string} traceId - ID of the trace to retrieve
455
+ * @returns {Object} Trace object
456
+ */
457
+ getTrace(traceId) {
458
+ return this.traces.get(traceId);
459
+ }
460
+
461
+ /**
462
+ * Get current performance metrics
463
+ *
464
+ * @returns {Object} Current metrics
465
+ */
466
+ getMetrics() {
467
+ const rpm = this._computeRequestsPerMinute();
468
+ const reqCount = this.metrics.requestCount || 0;
469
+ const errCount = this.metrics.errorCount || 0;
470
+ const minDur =
471
+ this.metrics.minDuration === Number.MAX_SAFE_INTEGER ? null : this.metrics.minDuration;
472
+
473
+ return {
474
+ ...this.metrics,
475
+ minDuration: minDur === null ? 0 : minDur,
476
+ requestsPerMinute: rpm,
477
+ currentLoadLevel: this._computeLoadLevel(rpm, reqCount, errCount),
478
+ endpointMetrics: this._serializeEndpointMetrics(),
479
+ countersScope: 'process',
480
+ instance: {
481
+ hostname: os.hostname(),
482
+ pid: process.pid,
483
+ processStartedAt: new Date(this._processStartedAt).toISOString(),
484
+ lastCountersResetAt: this._lastResetAt ? new Date(this._lastResetAt).toISOString() : null
485
+ },
486
+ rollingWindow: {
487
+ id: 'http_requests',
488
+ spanSeconds: RPM_WINDOW_SEC,
489
+ note: 'requestsPerMinute sums HTTP requests recorded in the rolling window (per process)'
490
+ }
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Reset performance metrics
496
+ */
497
+ resetMetrics() {
498
+ this.metrics = {
499
+ requestCount: 0,
500
+ errorCount: 0,
501
+ totalDuration: 0,
502
+ maxDuration: 0,
503
+ minDuration: Number.MAX_SAFE_INTEGER,
504
+ blockchainCalls: 0,
505
+ blockchainDuration: 0,
506
+ authenticationCalls: 0,
507
+ authenticationDuration: 0
508
+ };
509
+ this._lastResetAt = Date.now();
510
+ this._requestsBySecond.clear();
511
+ this._endpointStats.clear();
512
+
513
+ logger.info('Performance metrics reset', {
514
+ component: 'PerformanceService',
515
+ method: 'resetMetrics'
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Get appropriate log level based on duration
521
+ * @private
522
+ *
523
+ * @param {number} duration - Operation duration in ms
524
+ * @returns {string} Log level to use
525
+ */
526
+ _getDurationLogLevel(duration) {
527
+ if (duration > 1000) {
528
+ return 'warn'; // Over 1 second
529
+ } else if (duration > 500) {
530
+ return 'info'; // 500ms - 1 second
531
+ } else {
532
+ return 'debug'; // Under 500ms
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Get health resource usage metrics
538
+ *
539
+ * @returns {Object} System resource metrics
540
+ */
541
+ getSystemMetrics() {
542
+ const cpuUsage = process.cpuUsage();
543
+ const memoryUsage = process.memoryUsage();
544
+
545
+ return {
546
+ cpu: {
547
+ user: cpuUsage.user,
548
+ system: cpuUsage.system,
549
+ loadAvg: os.loadavg()
550
+ },
551
+ memory: {
552
+ rss: memoryUsage.rss,
553
+ heapTotal: memoryUsage.heapTotal,
554
+ heapUsed: memoryUsage.heapUsed,
555
+ external: memoryUsage.external,
556
+ arrayBuffers: memoryUsage.arrayBuffers
557
+ },
558
+ uptime: process.uptime(),
559
+ timestamp: Date.now()
560
+ };
561
+ }
562
+ }
563
+
564
+ // Create and export singleton instance
565
+ const performanceService = new PerformanceService();
566
+ // Initialize the service
567
+ performanceService.initialize();
568
+ module.exports = performanceService;