UNPKG

s3db.js

Version:

Use AWS S3, the world's most reliable document storage, as a database with this ORM.

628 lines (547 loc) 21.3 kB
import Plugin from "./plugin.class.js"; import tryFn from "../concerns/try-fn.js"; export class MetricsPlugin extends Plugin { constructor(options = {}) { super(); this.config = { collectPerformance: options.collectPerformance !== false, collectErrors: options.collectErrors !== false, collectUsage: options.collectUsage !== false, retentionDays: options.retentionDays || 30, flushInterval: options.flushInterval || 60000, // 1 minute ...options }; this.metrics = { operations: { insert: { count: 0, totalTime: 0, errors: 0 }, update: { count: 0, totalTime: 0, errors: 0 }, delete: { count: 0, totalTime: 0, errors: 0 }, get: { count: 0, totalTime: 0, errors: 0 }, list: { count: 0, totalTime: 0, errors: 0 }, count: { count: 0, totalTime: 0, errors: 0 } }, resources: {}, errors: [], performance: [], startTime: new Date().toISOString() }; this.flushTimer = null; } async setup(database) { this.database = database; if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') return; const [ok, err] = await tryFn(async () => { const [ok1, err1, metricsResource] = await tryFn(() => database.createResource({ name: 'metrics', attributes: { id: 'string|required', type: 'string|required', // 'operation', 'error', 'performance' resourceName: 'string', operation: 'string', count: 'number|required', totalTime: 'number|required', errors: 'number|required', avgTime: 'number|required', timestamp: 'string|required', metadata: 'json' } })); this.metricsResource = ok1 ? metricsResource : database.resources.metrics; const [ok2, err2, errorsResource] = await tryFn(() => database.createResource({ name: 'error_logs', attributes: { id: 'string|required', resourceName: 'string|required', operation: 'string|required', error: 'string|required', timestamp: 'string|required', metadata: 'json' } })); this.errorsResource = ok2 ? errorsResource : database.resources.error_logs; const [ok3, err3, performanceResource] = await tryFn(() => database.createResource({ name: 'performance_logs', attributes: { id: 'string|required', resourceName: 'string|required', operation: 'string|required', duration: 'number|required', timestamp: 'string|required', metadata: 'json' } })); this.performanceResource = ok3 ? performanceResource : database.resources.performance_logs; }); if (!ok) { // Resources might already exist this.metricsResource = database.resources.metrics; this.errorsResource = database.resources.error_logs; this.performanceResource = database.resources.performance_logs; } // Use database hooks for automatic resource discovery this.installDatabaseHooks(); // Install hooks for existing resources this.installMetricsHooks(); // Disable flush timer during tests to avoid side effects if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') { this.startFlushTimer(); } } async start() { // Plugin is ready } async stop() { // Stop flush timer if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } // Remove database hooks this.removeDatabaseHooks(); } installDatabaseHooks() { // Use the new database hooks system for automatic resource discovery this.database.addHook('afterCreateResource', (resource) => { if (resource.name !== 'metrics' && resource.name !== 'error_logs' && resource.name !== 'performance_logs') { this.installResourceHooks(resource); } }); } removeDatabaseHooks() { // Remove the hook we added this.database.removeHook('afterCreateResource', this.installResourceHooks.bind(this)); } installMetricsHooks() { // Only hook into non-metrics resources for (const resource of Object.values(this.database.resources)) { if (['metrics', 'error_logs', 'performance_logs'].includes(resource.name)) { continue; // Skip metrics resources to avoid recursion } this.installResourceHooks(resource); } // Hook into database proxy for new resources this.database._createResource = this.database.createResource; this.database.createResource = async function (...args) { const resource = await this._createResource(...args); if (this.plugins?.metrics && !['metrics', 'error_logs', 'performance_logs'].includes(resource.name)) { this.plugins.metrics.installResourceHooks(resource); } return resource; }; } installResourceHooks(resource) { // Store original methods resource._insert = resource.insert; resource._update = resource.update; resource._delete = resource.delete; resource._deleteMany = resource.deleteMany; resource._get = resource.get; resource._getMany = resource.getMany; resource._getAll = resource.getAll; resource._list = resource.list; resource._listIds = resource.listIds; resource._count = resource.count; resource._page = resource.page; // Hook insert operations resource.insert = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._insert(...args)); this.recordOperation(resource.name, 'insert', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'insert', err); if (!ok) throw err; return result; }.bind(this); // Hook update operations resource.update = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._update(...args)); this.recordOperation(resource.name, 'update', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'update', err); if (!ok) throw err; return result; }.bind(this); // Hook delete operations resource.delete = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._delete(...args)); this.recordOperation(resource.name, 'delete', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'delete', err); if (!ok) throw err; return result; }.bind(this); // Hook deleteMany operations resource.deleteMany = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._deleteMany(...args)); this.recordOperation(resource.name, 'delete', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'delete', err); if (!ok) throw err; return result; }.bind(this); // Hook get operations resource.get = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._get(...args)); this.recordOperation(resource.name, 'get', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'get', err); if (!ok) throw err; return result; }.bind(this); // Hook getMany operations resource.getMany = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._getMany(...args)); this.recordOperation(resource.name, 'get', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'get', err); if (!ok) throw err; return result; }.bind(this); // Hook getAll operations resource.getAll = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._getAll(...args)); this.recordOperation(resource.name, 'list', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'list', err); if (!ok) throw err; return result; }.bind(this); // Hook list operations resource.list = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._list(...args)); this.recordOperation(resource.name, 'list', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'list', err); if (!ok) throw err; return result; }.bind(this); // Hook listIds operations resource.listIds = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._listIds(...args)); this.recordOperation(resource.name, 'list', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'list', err); if (!ok) throw err; return result; }.bind(this); // Hook count operations resource.count = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._count(...args)); this.recordOperation(resource.name, 'count', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'count', err); if (!ok) throw err; return result; }.bind(this); // Hook page operations resource.page = async function (...args) { const startTime = Date.now(); const [ok, err, result] = await tryFn(() => resource._page(...args)); this.recordOperation(resource.name, 'list', Date.now() - startTime, !ok); if (!ok) this.recordError(resource.name, 'list', err); if (!ok) throw err; return result; }.bind(this); } recordOperation(resourceName, operation, duration, isError) { // Update global metrics if (this.metrics.operations[operation]) { this.metrics.operations[operation].count++; this.metrics.operations[operation].totalTime += duration; if (isError) { this.metrics.operations[operation].errors++; } } // Update resource-specific metrics if (!this.metrics.resources[resourceName]) { this.metrics.resources[resourceName] = { insert: { count: 0, totalTime: 0, errors: 0 }, update: { count: 0, totalTime: 0, errors: 0 }, delete: { count: 0, totalTime: 0, errors: 0 }, get: { count: 0, totalTime: 0, errors: 0 }, list: { count: 0, totalTime: 0, errors: 0 }, count: { count: 0, totalTime: 0, errors: 0 } }; } if (this.metrics.resources[resourceName][operation]) { this.metrics.resources[resourceName][operation].count++; this.metrics.resources[resourceName][operation].totalTime += duration; if (isError) { this.metrics.resources[resourceName][operation].errors++; } } // Record performance data if enabled if (this.config.collectPerformance) { this.metrics.performance.push({ resourceName, operation, duration, timestamp: new Date().toISOString() }); } } recordError(resourceName, operation, error) { if (!this.config.collectErrors) return; this.metrics.errors.push({ resourceName, operation, error: error.message, stack: error.stack, timestamp: new Date().toISOString() }); } startFlushTimer() { if (this.flushTimer) { clearInterval(this.flushTimer); } // Only start timer if flushInterval is greater than 0 if (this.config.flushInterval > 0) { this.flushTimer = setInterval(() => { this.flushMetrics().catch(() => {}); }, this.config.flushInterval); } } async flushMetrics() { if (!this.metricsResource) return; const [ok, err] = await tryFn(async () => { let metadata, perfMetadata, errorMetadata, resourceMetadata; if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { // Use empty metadata during tests to avoid header issues metadata = {}; perfMetadata = {}; errorMetadata = {}; resourceMetadata = {}; } else { // Use empty metadata during tests to avoid header issues metadata = { global: 'true' }; perfMetadata = { perf: 'true' }; errorMetadata = { error: 'true' }; resourceMetadata = { resource: 'true' }; } // Flush operation metrics for (const [operation, data] of Object.entries(this.metrics.operations)) { if (data.count > 0) { await this.metricsResource.insert({ id: `metrics-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type: 'operation', resourceName: 'global', operation, count: data.count, totalTime: data.totalTime, errors: data.errors, avgTime: data.count > 0 ? data.totalTime / data.count : 0, timestamp: new Date().toISOString(), metadata }); } } // Flush resource-specific metrics for (const [resourceName, operations] of Object.entries(this.metrics.resources)) { for (const [operation, data] of Object.entries(operations)) { if (data.count > 0) { await this.metricsResource.insert({ id: `metrics-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type: 'operation', resourceName, operation, count: data.count, totalTime: data.totalTime, errors: data.errors, avgTime: data.count > 0 ? data.totalTime / data.count : 0, timestamp: new Date().toISOString(), metadata: resourceMetadata }); } } } // Flush performance logs if (this.config.collectPerformance && this.metrics.performance.length > 0) { for (const perf of this.metrics.performance) { await this.performanceResource.insert({ id: `perf-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, resourceName: perf.resourceName, operation: perf.operation, duration: perf.duration, timestamp: perf.timestamp, metadata: perfMetadata }); } } // Flush error logs if (this.config.collectErrors && this.metrics.errors.length > 0) { for (const error of this.metrics.errors) { await this.errorsResource.insert({ id: `error-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, resourceName: error.resourceName, operation: error.operation, error: error.error, stack: error.stack, timestamp: error.timestamp, metadata: errorMetadata }); } } // Reset metrics after flushing this.resetMetrics(); }); if (!ok) { // Silent error handling } } resetMetrics() { // Reset operation metrics for (const operation of Object.keys(this.metrics.operations)) { this.metrics.operations[operation] = { count: 0, totalTime: 0, errors: 0 }; } // Reset resource metrics for (const resourceName of Object.keys(this.metrics.resources)) { for (const operation of Object.keys(this.metrics.resources[resourceName])) { this.metrics.resources[resourceName][operation] = { count: 0, totalTime: 0, errors: 0 }; } } // Clear performance and error arrays this.metrics.performance = []; this.metrics.errors = []; } // Utility methods async getMetrics(options = {}) { const { type = 'operation', resourceName, operation, startDate, endDate, limit = 100, offset = 0 } = options; if (!this.metricsResource) return []; const allMetrics = await this.metricsResource.getAll(); let filtered = allMetrics.filter(metric => { if (type && metric.type !== type) return false; if (resourceName && metric.resourceName !== resourceName) return false; if (operation && metric.operation !== operation) return false; if (startDate && new Date(metric.timestamp) < new Date(startDate)) return false; if (endDate && new Date(metric.timestamp) > new Date(endDate)) return false; return true; }); // Sort by timestamp descending filtered.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); return filtered.slice(offset, offset + limit); } async getErrorLogs(options = {}) { if (!this.errorsResource) return []; const { resourceName, operation, startDate, endDate, limit = 100, offset = 0 } = options; const allErrors = await this.errorsResource.getAll(); let filtered = allErrors.filter(error => { if (resourceName && error.resourceName !== resourceName) return false; if (operation && error.operation !== operation) return false; if (startDate && new Date(error.timestamp) < new Date(startDate)) return false; if (endDate && new Date(error.timestamp) > new Date(endDate)) return false; return true; }); // Sort by timestamp descending filtered.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); return filtered.slice(offset, offset + limit); } async getPerformanceLogs(options = {}) { if (!this.performanceResource) return []; const { resourceName, operation, startDate, endDate, limit = 100, offset = 0 } = options; const allPerformance = await this.performanceResource.getAll(); let filtered = allPerformance.filter(perf => { if (resourceName && perf.resourceName !== resourceName) return false; if (operation && perf.operation !== operation) return false; if (startDate && new Date(perf.timestamp) < new Date(startDate)) return false; if (endDate && new Date(perf.timestamp) > new Date(endDate)) return false; return true; }); // Sort by timestamp descending filtered.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); return filtered.slice(offset, offset + limit); } async getStats() { const now = new Date(); const startDate = new Date(now.getTime() - (24 * 60 * 60 * 1000)); // Last 24 hours const [metrics, errors, performance] = await Promise.all([ this.getMetrics({ startDate: startDate.toISOString() }), this.getErrorLogs({ startDate: startDate.toISOString() }), this.getPerformanceLogs({ startDate: startDate.toISOString() }) ]); // Calculate summary statistics const stats = { period: '24h', totalOperations: 0, totalErrors: errors.length, avgResponseTime: 0, operationsByType: {}, resources: {}, uptime: { startTime: this.metrics.startTime, duration: now.getTime() - new Date(this.metrics.startTime).getTime() } }; // Aggregate metrics for (const metric of metrics) { if (metric.type === 'operation') { stats.totalOperations += metric.count; if (!stats.operationsByType[metric.operation]) { stats.operationsByType[metric.operation] = { count: 0, errors: 0, avgTime: 0 }; } stats.operationsByType[metric.operation].count += metric.count; stats.operationsByType[metric.operation].errors += metric.errors; // Calculate weighted average const current = stats.operationsByType[metric.operation]; const totalCount = current.count; const newAvg = ((current.avgTime * (totalCount - metric.count)) + metric.totalTime) / totalCount; current.avgTime = newAvg; } } // Calculate overall average response time const totalTime = metrics.reduce((sum, m) => sum + m.totalTime, 0); const totalCount = metrics.reduce((sum, m) => sum + m.count, 0); stats.avgResponseTime = totalCount > 0 ? totalTime / totalCount : 0; return stats; } async cleanupOldData() { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - this.config.retentionDays); // Clean up old metrics if (this.metricsResource) { const oldMetrics = await this.getMetrics({ endDate: cutoffDate.toISOString() }); for (const metric of oldMetrics) { await this.metricsResource.delete(metric.id); } } // Clean up old error logs if (this.errorsResource) { const oldErrors = await this.getErrorLogs({ endDate: cutoffDate.toISOString() }); for (const error of oldErrors) { await this.errorsResource.delete(error.id); } } // Clean up old performance logs if (this.performanceResource) { const oldPerformance = await this.getPerformanceLogs({ endDate: cutoffDate.toISOString() }); for (const perf of oldPerformance) { await this.performanceResource.delete(perf.id); } } } } export default MetricsPlugin;