@ahmedhegazee/nestjs-telescope
Version:
Advanced observability and monitoring solution for NestJS applications with ML-powered analytics, enterprise features, and production-ready scaling
584 lines • 23.1 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var JobWatcherService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.JobWatcherService = exports.JobStatus = void 0;
const common_1 = require("@nestjs/common");
const telescope_service_1 = require("../../core/services/telescope.service");
const job_watcher_config_1 = require("./job-watcher.config");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
var JobStatus;
(function (JobStatus) {
JobStatus["WAITING"] = "waiting";
JobStatus["ACTIVE"] = "active";
JobStatus["COMPLETED"] = "completed";
JobStatus["FAILED"] = "failed";
JobStatus["DELAYED"] = "delayed";
JobStatus["PAUSED"] = "paused";
JobStatus["STALLED"] = "stalled";
JobStatus["CANCELLED"] = "cancelled";
})(JobStatus || (exports.JobStatus = JobStatus = {}));
let JobWatcherService = JobWatcherService_1 = class JobWatcherService {
constructor(telescopeService, jobWatcherConfig) {
this.telescopeService = telescopeService;
this.logger = new common_1.Logger(JobWatcherService_1.name);
this.destroy$ = new rxjs_1.Subject();
this.metricsSubject = new rxjs_1.BehaviorSubject(this.initializeMetrics());
this.alertsSubject = new rxjs_1.Subject();
this.jobContextSubject = new rxjs_1.Subject();
this.jobHistory = [];
this.queueMetrics = new Map();
this.workerMetrics = new Map();
this.activeJobs = new Map();
this.currentMetrics = this.initializeMetrics();
this.alertHistory = [];
this.executionTimes = [];
this.waitTimes = [];
this.failureHistory = [];
this.config = { ...job_watcher_config_1.defaultJobWatcherConfig, ...jobWatcherConfig };
}
async onModuleInit() {
if (!this.config.enabled) {
return;
}
this.startPeriodicProcessing();
this.logger.log('Job watcher initialized');
}
onModuleDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
initializeMetrics() {
return {
totalJobs: 0,
completedJobs: 0,
failedJobs: 0,
activeJobs: 0,
waitingJobs: 0,
delayedJobs: 0,
stalledJobs: 0,
averageExecutionTime: 0,
averageWaitTime: 0,
slowJobs: 0,
jobsPerMinute: 0,
jobsPerHour: 0,
failureRate: 0,
throughput: 0,
queuesByStatus: {},
queuesByType: {},
activeWorkers: 0,
busyWorkers: 0,
idleWorkers: 0,
topFailedJobs: [],
topSlowJobs: [],
healthScore: 100,
healthStatus: 'healthy',
trends: {
lastHour: this.initializeTrendData(),
lastDay: this.initializeTrendData(),
lastWeek: this.initializeTrendData(),
},
};
}
initializeTrendData() {
return {
totalJobs: 0,
completedJobs: 0,
failedJobs: 0,
averageExecutionTime: 0,
change: 0,
changePercent: 0,
peak: 0,
};
}
trackJob(context) {
if (!this.config.enabled) {
return;
}
try {
if (Math.random() * 100 > this.config.sampleRate) {
return;
}
if (this.shouldExcludeJob(context)) {
return;
}
this.addToHistory(context);
this.updateActiveJobs(context);
this.updateMetrics(context);
const entry = this.createTelescopeEntry(context);
this.telescopeService.record(entry);
this.checkAlerts(context);
this.correlateJob(context);
this.jobContextSubject.next(context);
this.logger.debug(`Job tracked: ${context.jobName} (${context.id})`);
}
catch (error) {
this.logger.error('Failed to track job:', error);
}
}
shouldExcludeJob(context) {
if (this.config.excludeJobTypes.includes(context.jobName)) {
return true;
}
if (this.config.excludeQueues.includes(context.queueName)) {
return true;
}
return false;
}
addToHistory(context) {
this.jobHistory.push(context);
if (this.jobHistory.length > this.config.maxHistorySize) {
this.jobHistory.shift();
}
const retentionDate = new Date(Date.now() - this.config.retentionPeriod);
this.jobHistory = this.jobHistory.filter(job => job.timestamp > retentionDate);
}
updateActiveJobs(context) {
const key = `${context.queueName}:${context.jobId}`;
if (context.status === JobStatus.ACTIVE) {
this.activeJobs.set(key, context);
}
else if (context.status === JobStatus.COMPLETED || context.status === JobStatus.FAILED) {
this.activeJobs.delete(key);
}
}
updateMetrics(context) {
this.currentMetrics.totalJobs++;
switch (context.status) {
case JobStatus.COMPLETED:
this.currentMetrics.completedJobs++;
break;
case JobStatus.FAILED:
this.currentMetrics.failedJobs++;
this.trackFailure(context);
break;
case JobStatus.ACTIVE:
this.currentMetrics.activeJobs++;
break;
case JobStatus.WAITING:
this.currentMetrics.waitingJobs++;
break;
case JobStatus.DELAYED:
this.currentMetrics.delayedJobs++;
break;
case JobStatus.STALLED:
this.currentMetrics.stalledJobs++;
break;
}
if (context.duration) {
this.executionTimes.push(context.duration);
this.currentMetrics.averageExecutionTime = this.calculateAverage(this.executionTimes);
if (context.duration > this.config.slowJobThreshold) {
this.currentMetrics.slowJobs++;
}
}
if (context.performance?.queueWaitTime) {
this.waitTimes.push(context.performance.queueWaitTime);
this.currentMetrics.averageWaitTime = this.calculateAverage(this.waitTimes);
}
this.currentMetrics.queuesByStatus[context.status] =
(this.currentMetrics.queuesByStatus[context.status] || 0) + 1;
this.currentMetrics.queuesByType[context.jobName] =
(this.currentMetrics.queuesByType[context.jobName] || 0) + 1;
this.updateRates();
this.updateTopJobs();
this.updateHealthScore();
}
trackFailure(context) {
this.failureHistory.push({
timestamp: context.timestamp,
jobName: context.jobName,
queueName: context.queueName,
});
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
this.failureHistory = this.failureHistory.filter(f => f.timestamp > oneHourAgo);
}
calculateAverage(values) {
if (values.length === 0)
return 0;
return values.reduce((sum, val) => sum + val, 0) / values.length;
}
updateRates() {
const now = Date.now();
const oneMinuteAgo = now - 60 * 1000;
const oneHourAgo = now - 60 * 60 * 1000;
const jobsLastMinute = this.jobHistory.filter(job => job.timestamp.getTime() > oneMinuteAgo).length;
const jobsLastHour = this.jobHistory.filter(job => job.timestamp.getTime() > oneHourAgo).length;
this.currentMetrics.jobsPerMinute = jobsLastMinute;
this.currentMetrics.jobsPerHour = jobsLastHour;
const totalJobs = this.currentMetrics.totalJobs || 1;
this.currentMetrics.failureRate = (this.currentMetrics.failedJobs / totalJobs) * 100;
this.currentMetrics.throughput = jobsLastMinute / 60;
}
updateTopJobs() {
const failureGroups = new Map();
this.failureHistory.forEach(failure => {
const key = `${failure.queueName}:${failure.jobName}`;
const existing = failureGroups.get(key) || { count: 0, lastFailure: failure.timestamp, execTimes: [] };
existing.count++;
existing.lastFailure = failure.timestamp;
failureGroups.set(key, existing);
});
this.currentMetrics.topFailedJobs = Array.from(failureGroups.entries())
.map(([key, data]) => {
const [queueName, jobName] = key.split(':');
return {
queueName,
jobName,
failureCount: data.count,
lastFailure: data.lastFailure,
avgExecutionTime: this.calculateAverage(data.execTimes),
};
})
.sort((a, b) => b.failureCount - a.failureCount)
.slice(0, 10);
const slowJobGroups = new Map();
this.jobHistory
.filter(job => job.duration && job.duration > this.config.slowJobThreshold)
.forEach(job => {
const key = `${job.queueName}:${job.jobName}`;
const existing = slowJobGroups.get(key) || { execTimes: [], lastExecution: job.timestamp };
existing.execTimes.push(job.duration);
existing.lastExecution = job.timestamp;
slowJobGroups.set(key, existing);
});
this.currentMetrics.topSlowJobs = Array.from(slowJobGroups.entries())
.map(([key, data]) => {
const [queueName, jobName] = key.split(':');
return {
queueName,
jobName,
avgExecutionTime: this.calculateAverage(data.execTimes),
executionCount: data.execTimes.length,
lastExecution: data.lastExecution,
};
})
.sort((a, b) => b.avgExecutionTime - a.avgExecutionTime)
.slice(0, 10);
}
updateHealthScore() {
let score = 100;
const issues = [];
if (this.currentMetrics.failureRate > 20) {
score -= 30;
issues.push('High failure rate');
}
else if (this.currentMetrics.failureRate > 10) {
score -= 15;
issues.push('Moderate failure rate');
}
if (this.currentMetrics.slowJobs > this.currentMetrics.totalJobs * 0.2) {
score -= 20;
issues.push('Many slow jobs');
}
if (this.currentMetrics.stalledJobs > 0) {
score -= 25;
issues.push('Stalled jobs detected');
}
if (this.currentMetrics.averageWaitTime > 30000) {
score -= 15;
issues.push('High queue wait times');
}
this.currentMetrics.healthScore = Math.max(0, score);
if (score >= 80) {
this.currentMetrics.healthStatus = 'healthy';
}
else if (score >= 60) {
this.currentMetrics.healthStatus = 'warning';
}
else {
this.currentMetrics.healthStatus = 'critical';
}
}
createTelescopeEntry(context) {
const entryId = `job_${context.id}`;
const familyHash = `${context.queueName}:${context.jobName}`;
return {
id: entryId,
type: 'job',
familyHash,
content: {
job: {
id: context.jobId,
name: context.jobName,
queue: context.queueName,
status: context.status,
priority: context.priority,
attempts: context.attempts,
maxAttempts: context.maxAttempts,
delay: context.delay,
progress: context.progress,
},
execution: {
startTime: context.startTime,
endTime: context.endTime,
duration: context.duration,
result: this.config.captureJobResults ? context.result : undefined,
error: this.config.captureJobErrors ? context.error : undefined,
},
data: this.config.captureJobData ? this.sanitizeJobData(context.data) : undefined,
performance: context.performance,
correlation: {
traceId: context.traceId,
requestId: context.requestId,
userId: context.userId,
sessionId: context.sessionId,
},
worker: {
id: context.workerId,
name: context.workerName,
},
queueStats: context.queueStats,
},
tags: this.generateTags(context),
timestamp: context.timestamp,
sequence: context.timestamp.getTime(),
};
}
sanitizeJobData(data) {
if (!data || typeof data !== 'object') {
return data;
}
const sanitized = { ...data };
const sensitiveFields = ['password', 'token', 'secret', 'key', 'auth', 'apiKey'];
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
}
const jsonString = JSON.stringify(sanitized);
if (jsonString.length > this.config.maxDataSize) {
return { _truncated: true, _size: jsonString.length };
}
return sanitized;
}
generateTags(context) {
const tags = [
'job',
`queue:${context.queueName}`,
`job:${context.jobName}`,
`status:${context.status}`,
];
if (context.priority) {
tags.push(`priority:${context.priority}`);
}
if (context.attempts > 1) {
tags.push('retry');
}
if (context.delay) {
tags.push('delayed');
}
if (context.workerId) {
tags.push(`worker:${context.workerId}`);
}
return tags;
}
checkAlerts(context) {
if (!this.config.enableRealTimeAlerts) {
return;
}
const now = Date.now();
const timeWindow = this.config.alertThresholds.timeWindow;
if (this.currentMetrics.failureRate > this.config.alertThresholds.failureRate) {
this.createAlert({
type: 'failure_rate',
severity: 'high',
message: `Job failure rate exceeded threshold: ${this.currentMetrics.failureRate.toFixed(1)}%`,
data: { failureRate: this.currentMetrics.failureRate, threshold: this.config.alertThresholds.failureRate },
queueName: context.queueName,
});
}
if (context.duration && context.duration > this.config.alertThresholds.avgExecutionTime) {
this.createAlert({
type: 'slow_jobs',
severity: 'medium',
message: `Slow job detected: ${context.jobName} took ${context.duration}ms`,
data: { duration: context.duration, threshold: this.config.alertThresholds.avgExecutionTime },
queueName: context.queueName,
jobName: context.jobName,
});
}
if (this.currentMetrics.stalledJobs >= this.config.alertThresholds.stalledJobs) {
this.createAlert({
type: 'stalled_jobs',
severity: 'critical',
message: `Stalled jobs detected: ${this.currentMetrics.stalledJobs} jobs`,
data: { stalledJobs: this.currentMetrics.stalledJobs, threshold: this.config.alertThresholds.stalledJobs },
});
}
}
createAlert(alert) {
const fullAlert = {
id: `job_alert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date(),
acknowledged: false,
...alert,
};
this.alertHistory.push(fullAlert);
this.alertsSubject.next(fullAlert);
this.logger.warn(`Job alert: ${fullAlert.message}`, fullAlert.data);
}
correlateJob(context) {
if (context.traceId) {
this.logger.debug(`Correlated job ${context.jobName} with trace ${context.traceId}`);
}
}
startPeriodicProcessing() {
(0, rxjs_1.interval)(30000)
.pipe((0, operators_1.takeUntil)(this.destroy$))
.subscribe(() => {
this.updateTrends();
this.cleanupOldData();
this.metricsSubject.next({ ...this.currentMetrics });
});
}
updateTrends() {
const now = Date.now();
const oneHourAgo = now - 60 * 60 * 1000;
const oneDayAgo = now - 24 * 60 * 60 * 1000;
const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000;
this.currentMetrics.trends.lastHour = this.calculateTrend(oneHourAgo);
this.currentMetrics.trends.lastDay = this.calculateTrend(oneDayAgo);
this.currentMetrics.trends.lastWeek = this.calculateTrend(oneWeekAgo);
}
calculateTrend(since) {
const jobs = this.jobHistory.filter(job => job.timestamp.getTime() > since);
const completed = jobs.filter(job => job.status === JobStatus.COMPLETED);
const failed = jobs.filter(job => job.status === JobStatus.FAILED);
const execTimes = jobs.filter(job => job.duration).map(job => job.duration);
return {
totalJobs: jobs.length,
completedJobs: completed.length,
failedJobs: failed.length,
averageExecutionTime: this.calculateAverage(execTimes),
change: 0,
changePercent: 0,
peak: execTimes.length > 0 ? Math.max(...execTimes) : 0,
};
}
cleanupOldData() {
const now = Date.now();
const retentionDate = new Date(now - this.config.retentionPeriod);
this.jobHistory = this.jobHistory.filter(job => job.timestamp > retentionDate);
this.alertHistory = this.alertHistory.filter(alert => alert.timestamp > retentionDate);
if (this.executionTimes.length > 1000) {
this.executionTimes = this.executionTimes.slice(-1000);
}
if (this.waitTimes.length > 1000) {
this.waitTimes = this.waitTimes.slice(-1000);
}
}
getMetrics() {
return { ...this.currentMetrics };
}
getMetricsStream() {
return this.metricsSubject.asObservable().pipe((0, operators_1.shareReplay)(1));
}
getAlertsStream() {
return this.alertsSubject.asObservable();
}
getJobStream() {
return this.jobContextSubject.asObservable();
}
getRecentJobs(limit = 100) {
return this.jobHistory.slice(-limit).reverse();
}
getJobsByQueue(queueName, limit = 100) {
return this.jobHistory
.filter(job => job.queueName === queueName)
.slice(-limit)
.reverse();
}
getJobsByStatus(status, limit = 100) {
return this.jobHistory
.filter(job => job.status === status)
.slice(-limit)
.reverse();
}
getQueueHealth(queueName) {
if (queueName) {
return this.calculateQueueHealth(queueName);
}
const queues = new Set(this.jobHistory.map(job => job.queueName));
return Array.from(queues).map(queue => this.calculateQueueHealth(queue));
}
calculateQueueHealth(queueName) {
const queueJobs = this.jobHistory.filter(job => job.queueName === queueName);
const totalJobs = queueJobs.length;
const failedJobs = queueJobs.filter(job => job.status === JobStatus.FAILED).length;
const stalledJobs = queueJobs.filter(job => job.status === JobStatus.STALLED).length;
const execTimes = queueJobs.filter(job => job.duration).map(job => job.duration);
const waitTimes = queueJobs.filter(job => job.performance?.queueWaitTime).map(job => job.performance.queueWaitTime);
const failureRate = totalJobs > 0 ? (failedJobs / totalJobs) * 100 : 0;
const avgWaitTime = this.calculateAverage(waitTimes);
const processingRate = totalJobs > 0 ? (totalJobs - failedJobs) / totalJobs : 0;
let score = 100;
const issues = [];
const recommendations = [];
if (failureRate > 20) {
score -= 30;
issues.push('High failure rate');
recommendations.push('Investigate failing jobs and fix underlying issues');
}
if (stalledJobs > 0) {
score -= 25;
issues.push('Stalled jobs detected');
recommendations.push('Check worker health and restart if necessary');
}
if (avgWaitTime > 30000) {
score -= 15;
issues.push('High queue wait times');
recommendations.push('Consider adding more workers or optimizing job processing');
}
let status = 'healthy';
if (score < 60) {
status = 'critical';
}
else if (score < 80) {
status = 'warning';
}
return {
queueName,
status,
score,
issues,
recommendations,
metrics: {
totalJobs,
processingRate,
failureRate,
averageWaitTime: avgWaitTime,
stalledJobs,
},
};
}
acknowledgeAlert(alertId) {
const alert = this.alertHistory.find(a => a.id === alertId);
if (!alert)
return false;
alert.acknowledged = true;
return true;
}
getConfig() {
return { ...this.config };
}
};
exports.JobWatcherService = JobWatcherService;
exports.JobWatcherService = JobWatcherService = JobWatcherService_1 = __decorate([
(0, common_1.Injectable)(),
__param(1, (0, common_1.Inject)('JOB_WATCHER_CONFIG')),
__metadata("design:paramtypes", [telescope_service_1.TelescopeService, Object])
], JobWatcherService);
//# sourceMappingURL=job-watcher.service.js.map