@oxog/spark
Version:
Ultra-fast, zero-dependency Node.js web framework with security hardening, memory leak protection, and enhanced error handling
328 lines (284 loc) • 8.55 kB
JavaScript
/**
* Metrics collection middleware for performance monitoring
*/
class MetricsCollector {
constructor() {
this.metrics = {
requests: {
total: 0,
byMethod: {},
byStatus: {},
byPath: {}
},
responseTime: {
total: 0,
count: 0,
min: Infinity,
max: 0,
buckets: {
'<100ms': 0,
'100-500ms': 0,
'500ms-1s': 0,
'1s-5s': 0,
'>5s': 0
}
},
errors: {
total: 0,
byType: {},
byStatus: {}
},
activeConnections: 0,
memory: {
samples: [],
maxSamples: 100
}
};
this.startTime = Date.now();
this.memoryInterval = null;
this.setupMemoryTracking();
}
setupMemoryTracking() {
this.memoryInterval = setInterval(() => {
const memUsage = process.memoryUsage();
this.metrics.memory.samples.push({
timestamp: Date.now(),
...memUsage
});
// Keep only last 100 samples
if (this.metrics.memory.samples.length > this.metrics.memory.maxSamples) {
this.metrics.memory.samples.shift();
}
}, 30000); // Every 30 seconds
// Don't keep process alive
this.memoryInterval.unref();
}
recordRequest(method, path, status, duration, error = null) {
// Total requests
this.metrics.requests.total++;
// By method
this.metrics.requests.byMethod[method] = (this.metrics.requests.byMethod[method] || 0) + 1;
// By status
this.metrics.requests.byStatus[status] = (this.metrics.requests.byStatus[status] || 0) + 1;
// By path (sanitized)
const sanitizedPath = this.sanitizePath(path);
this.metrics.requests.byPath[sanitizedPath] = (this.metrics.requests.byPath[sanitizedPath] || 0) + 1;
// Response time
this.metrics.responseTime.total += duration;
this.metrics.responseTime.count++;
this.metrics.responseTime.min = Math.min(this.metrics.responseTime.min, duration);
this.metrics.responseTime.max = Math.max(this.metrics.responseTime.max, duration);
// Response time buckets
if (duration < 100) {
this.metrics.responseTime.buckets['<100ms']++;
} else if (duration < 500) {
this.metrics.responseTime.buckets['100-500ms']++;
} else if (duration < 1000) {
this.metrics.responseTime.buckets['500ms-1s']++;
} else if (duration < 5000) {
this.metrics.responseTime.buckets['1s-5s']++;
} else {
this.metrics.responseTime.buckets['>5s']++;
}
// Errors
if (error || status >= 400) {
this.metrics.errors.total++;
this.metrics.errors.byStatus[status] = (this.metrics.errors.byStatus[status] || 0) + 1;
if (error) {
const errorType = error.constructor.name;
this.metrics.errors.byType[errorType] = (this.metrics.errors.byType[errorType] || 0) + 1;
}
}
}
sanitizePath(path) {
// Replace IDs and numbers with placeholders
return path
.replace(/\/\d+/g, '/:id')
.replace(/\/[a-f0-9]{24}/g, '/:id') // MongoDB ObjectIds
.replace(/\/[a-f0-9-]{36}/g, '/:uuid'); // UUIDs
}
incrementActiveConnections() {
this.metrics.activeConnections++;
}
decrementActiveConnections() {
this.metrics.activeConnections = Math.max(0, this.metrics.activeConnections - 1);
}
getMetrics() {
const now = Date.now();
const uptime = now - this.startTime;
const avgResponseTime = this.metrics.responseTime.count > 0
? this.metrics.responseTime.total / this.metrics.responseTime.count
: 0;
return {
timestamp: now,
uptime: uptime,
requests: {
...this.metrics.requests,
rps: this.metrics.requests.total / (uptime / 1000) // requests per second
},
responseTime: {
...this.metrics.responseTime,
average: avgResponseTime,
min: this.metrics.responseTime.min === Infinity ? 0 : this.metrics.responseTime.min
},
errors: {
...this.metrics.errors,
errorRate: this.metrics.requests.total > 0
? (this.metrics.errors.total / this.metrics.requests.total * 100).toFixed(2) + '%'
: '0%'
},
activeConnections: this.metrics.activeConnections,
memory: this.getMemoryStats()
};
}
getMemoryStats() {
if (this.metrics.memory.samples.length === 0) {
return { current: process.memoryUsage() };
}
const latest = this.metrics.memory.samples[this.metrics.memory.samples.length - 1];
const samples = this.metrics.memory.samples;
const heapUsedSamples = samples.map(s => s.heapUsed);
const avg = heapUsedSamples.reduce((a, b) => a + b, 0) / heapUsedSamples.length;
const max = Math.max(...heapUsedSamples);
const min = Math.min(...heapUsedSamples);
return {
current: {
heapUsed: latest.heapUsed,
heapTotal: latest.heapTotal,
external: latest.external,
rss: latest.rss
},
stats: {
average: avg,
max: max,
min: min,
samples: samples.length
}
};
}
reset() {
this.metrics = {
requests: {
total: 0,
byMethod: {},
byStatus: {},
byPath: {}
},
responseTime: {
total: 0,
count: 0,
min: Infinity,
max: 0,
buckets: {
'<100ms': 0,
'100-500ms': 0,
'500ms-1s': 0,
'1s-5s': 0,
'>5s': 0
}
},
errors: {
total: 0,
byType: {},
byStatus: {}
},
activeConnections: 0,
memory: {
samples: [],
maxSamples: 100
}
};
this.startTime = Date.now();
}
stop() {
if (this.memoryInterval) {
clearInterval(this.memoryInterval);
this.memoryInterval = null;
}
}
}
// Global metrics collector instance
const globalCollector = new MetricsCollector();
/**
* Metrics middleware
* @param {Object} options - Metrics options
* @returns {Function} Middleware function
*/
function metrics(options = {}) {
const opts = {
path: '/_metrics',
collectResponseTime: true,
collectMemory: true,
excludePaths: ['/favicon.ico'],
customCollector: null,
...options
};
const collector = opts.customCollector || globalCollector;
const middleware = async (ctx, next) => {
// Serve metrics endpoint
if (ctx.path === opts.path) {
if (ctx.method !== 'GET') {
ctx.status(405).json({ error: 'Method Not Allowed' });
return;
}
const metricsData = collector.getMetrics();
ctx.json(metricsData);
return;
}
// Skip excluded paths
if (opts.excludePaths.includes(ctx.path)) {
return next();
}
// Track active connections
collector.incrementActiveConnections();
const startTime = Date.now();
let error = null;
try {
await next();
} catch (err) {
error = err;
throw err;
} finally {
const duration = Date.now() - startTime;
// Record metrics
collector.recordRequest(
ctx.method,
ctx.path,
ctx.statusCode,
duration,
error
);
// Add response time header
if (opts.collectResponseTime) {
ctx.set('X-Response-Time', `${duration}ms`);
}
// Decrement active connections
collector.decrementActiveConnections();
}
};
// Add cleanup method to middleware
middleware.cleanup = () => {
collector.stop();
};
// Add collector access
middleware.collector = collector;
return middleware;
}
/**
* Create a custom metrics collector
* @returns {MetricsCollector} New metrics collector instance
*/
function createCollector() {
return new MetricsCollector();
}
/**
* Get global metrics collector
* @returns {MetricsCollector} Global collector instance
*/
function getGlobalCollector() {
return globalCollector;
}
module.exports = metrics;
module.exports.createCollector = createCollector;
module.exports.getGlobalCollector = getGlobalCollector;
module.exports.MetricsCollector = MetricsCollector;