356 lines
No EOL
9.3 KiB
TypeScript
356 lines
No EOL
9.3 KiB
TypeScript
/**
|
|
* Monitoring Service
|
|
* Collects health and performance metrics from all system components
|
|
*/
|
|
|
|
import type { IServiceContainer } from '@stock-bot/handlers';
|
|
import { getLogger } from '@stock-bot/logger';
|
|
import type {
|
|
CacheStats,
|
|
QueueStats,
|
|
DatabaseStats,
|
|
SystemHealth,
|
|
ServiceMetrics,
|
|
MetricSnapshot
|
|
} from '../types/monitoring.types';
|
|
|
|
export class MonitoringService {
|
|
private readonly logger = getLogger('monitoring-service');
|
|
private startTime = Date.now();
|
|
|
|
constructor(private readonly container: IServiceContainer) {}
|
|
|
|
/**
|
|
* Get cache/Dragonfly statistics
|
|
*/
|
|
async getCacheStats(): Promise<CacheStats> {
|
|
try {
|
|
if (!this.container.cache) {
|
|
return {
|
|
provider: 'dragonfly',
|
|
connected: false,
|
|
};
|
|
}
|
|
|
|
// Get Redis/Dragonfly info
|
|
const info = await this.container.cache.info();
|
|
const dbSize = await this.container.cache.dbsize();
|
|
|
|
// Parse memory stats from info
|
|
const memoryUsed = this.parseInfoValue(info, 'used_memory');
|
|
const memoryPeak = this.parseInfoValue(info, 'used_memory_peak');
|
|
|
|
// Parse stats
|
|
const hits = this.parseInfoValue(info, 'keyspace_hits');
|
|
const misses = this.parseInfoValue(info, 'keyspace_misses');
|
|
const evictedKeys = this.parseInfoValue(info, 'evicted_keys');
|
|
const expiredKeys = this.parseInfoValue(info, 'expired_keys');
|
|
|
|
return {
|
|
provider: 'dragonfly',
|
|
connected: true,
|
|
uptime: this.parseInfoValue(info, 'uptime_in_seconds'),
|
|
memoryUsage: {
|
|
used: memoryUsed,
|
|
peak: memoryPeak,
|
|
},
|
|
stats: {
|
|
hits,
|
|
misses,
|
|
keys: dbSize,
|
|
evictedKeys,
|
|
expiredKeys,
|
|
},
|
|
info: this.parseRedisInfo(info),
|
|
};
|
|
} catch (error) {
|
|
this.logger.error('Failed to get cache stats', { error });
|
|
return {
|
|
provider: 'dragonfly',
|
|
connected: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get queue statistics
|
|
*/
|
|
async getQueueStats(): Promise<QueueStats[]> {
|
|
const stats: QueueStats[] = [];
|
|
|
|
try {
|
|
if (!this.container.queue) {
|
|
return stats;
|
|
}
|
|
|
|
// Get all queue names from the queue manager
|
|
const queueManager = this.container.queue;
|
|
const queueNames = ['default', 'proxy', 'qm', 'ib', 'ceo', 'webshare']; // Add your queue names
|
|
|
|
for (const queueName of queueNames) {
|
|
try {
|
|
const queue = queueManager.getQueue(queueName);
|
|
if (!queue) continue;
|
|
|
|
const [waiting, active, completed, failed, delayed, paused] = await Promise.all([
|
|
queue.getWaitingCount(),
|
|
queue.getActiveCount(),
|
|
queue.getCompletedCount(),
|
|
queue.getFailedCount(),
|
|
queue.getDelayedCount(),
|
|
queue.getPausedCount(),
|
|
]);
|
|
|
|
// Get worker info if available
|
|
const workers = queueManager.getWorker(queueName);
|
|
const workerInfo = workers ? {
|
|
count: 1, // Assuming single worker per queue
|
|
concurrency: workers.concurrency || 1,
|
|
} : undefined;
|
|
|
|
stats.push({
|
|
name: queueName,
|
|
connected: true,
|
|
jobs: {
|
|
waiting,
|
|
active,
|
|
completed,
|
|
failed,
|
|
delayed,
|
|
paused,
|
|
},
|
|
workers: workerInfo,
|
|
});
|
|
} catch (error) {
|
|
this.logger.warn(`Failed to get stats for queue ${queueName}`, { error });
|
|
stats.push({
|
|
name: queueName,
|
|
connected: false,
|
|
jobs: {
|
|
waiting: 0,
|
|
active: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
delayed: 0,
|
|
paused: 0,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.error('Failed to get queue stats', { error });
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Get database statistics
|
|
*/
|
|
async getDatabaseStats(): Promise<DatabaseStats[]> {
|
|
const stats: DatabaseStats[] = [];
|
|
|
|
// PostgreSQL stats
|
|
if (this.container.postgres) {
|
|
try {
|
|
const startTime = Date.now();
|
|
const result = await this.container.postgres.query('SELECT 1');
|
|
const latency = Date.now() - startTime;
|
|
|
|
// Get pool stats
|
|
const pool = (this.container.postgres as any).pool;
|
|
const poolStats = pool ? {
|
|
size: pool.totalCount || 0,
|
|
active: pool.idleCount || 0,
|
|
idle: pool.waitingCount || 0,
|
|
max: pool.options?.max || 0,
|
|
} : undefined;
|
|
|
|
stats.push({
|
|
type: 'postgres',
|
|
name: 'PostgreSQL',
|
|
connected: true,
|
|
latency,
|
|
pool: poolStats,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error('Failed to get PostgreSQL stats', { error });
|
|
stats.push({
|
|
type: 'postgres',
|
|
name: 'PostgreSQL',
|
|
connected: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// MongoDB stats
|
|
if (this.container.mongodb) {
|
|
try {
|
|
const startTime = Date.now();
|
|
const db = this.container.mongodb.db();
|
|
await db.admin().ping();
|
|
const latency = Date.now() - startTime;
|
|
|
|
const serverStatus = await db.admin().serverStatus();
|
|
|
|
stats.push({
|
|
type: 'mongodb',
|
|
name: 'MongoDB',
|
|
connected: true,
|
|
latency,
|
|
stats: {
|
|
version: serverStatus.version,
|
|
uptime: serverStatus.uptime,
|
|
connections: serverStatus.connections,
|
|
opcounters: serverStatus.opcounters,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.logger.error('Failed to get MongoDB stats', { error });
|
|
stats.push({
|
|
type: 'mongodb',
|
|
name: 'MongoDB',
|
|
connected: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// QuestDB stats
|
|
if (this.container.questdb) {
|
|
try {
|
|
const startTime = Date.now();
|
|
// QuestDB health check
|
|
const response = await fetch(`http://${process.env.QUESTDB_HOST || 'localhost'}:9000/exec?query=SELECT%201`);
|
|
const latency = Date.now() - startTime;
|
|
|
|
stats.push({
|
|
type: 'questdb',
|
|
name: 'QuestDB',
|
|
connected: response.ok,
|
|
latency,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error('Failed to get QuestDB stats', { error });
|
|
stats.push({
|
|
type: 'questdb',
|
|
name: 'QuestDB',
|
|
connected: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Get system health summary
|
|
*/
|
|
async getSystemHealth(): Promise<SystemHealth> {
|
|
const [cacheStats, queueStats, databaseStats] = await Promise.all([
|
|
this.getCacheStats(),
|
|
this.getQueueStats(),
|
|
this.getDatabaseStats(),
|
|
]);
|
|
|
|
const memory = process.memoryUsage();
|
|
const uptime = Date.now() - this.startTime;
|
|
|
|
// Determine overall health status
|
|
const errors: string[] = [];
|
|
|
|
if (!cacheStats.connected) {
|
|
errors.push('Cache service is disconnected');
|
|
}
|
|
|
|
const disconnectedQueues = queueStats.filter(q => !q.connected);
|
|
if (disconnectedQueues.length > 0) {
|
|
errors.push(`${disconnectedQueues.length} queue(s) are disconnected`);
|
|
}
|
|
|
|
const disconnectedDbs = databaseStats.filter(db => !db.connected);
|
|
if (disconnectedDbs.length > 0) {
|
|
errors.push(`${disconnectedDbs.length} database(s) are disconnected`);
|
|
}
|
|
|
|
const status = errors.length === 0 ? 'healthy' :
|
|
errors.length < 3 ? 'degraded' : 'unhealthy';
|
|
|
|
return {
|
|
status,
|
|
timestamp: new Date().toISOString(),
|
|
uptime,
|
|
memory: {
|
|
used: memory.heapUsed,
|
|
total: memory.heapTotal,
|
|
percentage: (memory.heapUsed / memory.heapTotal) * 100,
|
|
},
|
|
services: {
|
|
cache: cacheStats,
|
|
queues: queueStats,
|
|
databases: databaseStats,
|
|
},
|
|
errors: errors.length > 0 ? errors : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get service metrics (placeholder for future implementation)
|
|
*/
|
|
async getServiceMetrics(): Promise<ServiceMetrics> {
|
|
const now = new Date().toISOString();
|
|
|
|
return {
|
|
requestsPerSecond: {
|
|
timestamp: now,
|
|
value: 0,
|
|
unit: 'req/s',
|
|
},
|
|
averageResponseTime: {
|
|
timestamp: now,
|
|
value: 0,
|
|
unit: 'ms',
|
|
},
|
|
errorRate: {
|
|
timestamp: now,
|
|
value: 0,
|
|
unit: '%',
|
|
},
|
|
activeConnections: {
|
|
timestamp: now,
|
|
value: 0,
|
|
unit: 'connections',
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse value from Redis INFO output
|
|
*/
|
|
private parseInfoValue(info: string, key: string): number {
|
|
const match = info.match(new RegExp(`${key}:(\\d+)`));
|
|
return match ? parseInt(match[1], 10) : 0;
|
|
}
|
|
|
|
/**
|
|
* Parse Redis INFO into structured object
|
|
*/
|
|
private parseRedisInfo(info: string): Record<string, any> {
|
|
const result: Record<string, any> = {};
|
|
const sections = info.split('\r\n\r\n');
|
|
|
|
for (const section of sections) {
|
|
const lines = section.split('\r\n');
|
|
const sectionName = lines[0]?.replace('# ', '') || 'general';
|
|
result[sectionName] = {};
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const [key, value] = lines[i].split(':');
|
|
if (key && value) {
|
|
result[sectionName][key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
} |