add support for websocket api for real time updates.

This commit is contained in:
Fizer Khan
2025-12-05 16:22:59 +05:30
parent 5e5a20c9a7
commit 66569fe2ca
24 changed files with 1713 additions and 175 deletions
+99 -2
View File
@@ -1,6 +1,7 @@
import { connect, NatsConnection, JetStreamManager } from 'nats';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis';
import pino from 'pino';
import { config } from '../config';
import type { StreamMetrics, ConsumerMetrics, ClusterMetrics } from '../../../shared/src/index';
@@ -19,14 +20,24 @@ interface ClusterConnection {
serverUrl: string;
}
interface ConsumerStats {
delivered: number;
ackFloor: number;
timestamp: number;
}
const METRICS_CHANNEL = 'metrics';
export class MetricsCollector {
private prisma: PrismaClient;
private clickhouse: ClickHouseClient;
private redis: Redis;
private connections: Map<string, ClusterConnection> = new Map();
private streamMetricsInterval: NodeJS.Timeout | null = null;
private clusterMetricsInterval: NodeJS.Timeout | null = null;
private previousStreamStats: Map<string, { messages: number; bytes: number; timestamp: number }> =
new Map();
private previousConsumerStats: Map<string, ConsumerStats> = new Map();
constructor() {
this.prisma = new PrismaClient();
@@ -36,6 +47,7 @@ export class MetricsCollector {
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
this.redis = new Redis(config.REDIS_URL);
}
async start(): Promise<void> {
@@ -79,6 +91,7 @@ export class MetricsCollector {
this.connections.clear();
await this.clickhouse.close();
await this.redis.quit();
await this.prisma.$disconnect();
logger.info('Metrics collector stopped');
@@ -177,6 +190,31 @@ export class MetricsCollector {
// Collect consumer metrics for this stream
try {
for await (const consumerInfo of conn.jsm.consumers.list(streamName)) {
const consumerKey = `${clusterId}:${streamName}:${consumerInfo.name}`;
const prevConsumer = this.previousConsumerStats.get(consumerKey);
const now = Date.now();
// Get delivered and ack sequences
const delivered = consumerInfo.delivered?.stream_seq || 0;
const ackFloor = consumerInfo.ack_floor?.stream_seq || 0;
let deliveredRate = 0;
let ackRate = 0;
if (prevConsumer) {
const timeDiff = (now - prevConsumer.timestamp) / 1000; // seconds
if (timeDiff > 0) {
deliveredRate = Math.max(0, (delivered - prevConsumer.delivered) / timeDiff);
ackRate = Math.max(0, (ackFloor - prevConsumer.ackFloor) / timeDiff);
}
}
this.previousConsumerStats.set(consumerKey, {
delivered,
ackFloor,
timestamp: now,
});
consumerMetrics.push({
clusterId,
streamName,
@@ -186,8 +224,8 @@ export class MetricsCollector {
ackPending: consumerInfo.num_ack_pending,
redelivered: consumerInfo.num_redelivered,
waiting: consumerInfo.num_waiting,
deliveredRate: 0, // TODO: Calculate from delivered sequence
ackRate: 0, // TODO: Calculate from ack floor
deliveredRate,
ackRate,
lag: consumerInfo.num_pending,
});
}
@@ -222,6 +260,9 @@ export class MetricsCollector {
});
logger.debug({ count: streamMetrics.length }, 'Inserted stream metrics');
// Publish to Redis for real-time streaming
await this.publishStreamMetrics(streamMetrics);
} catch (err) {
logger.error({ err }, 'Error inserting stream metrics');
}
@@ -248,12 +289,68 @@ export class MetricsCollector {
});
logger.debug({ count: consumerMetrics.length }, 'Inserted consumer metrics');
// Publish to Redis for real-time streaming
await this.publishConsumerMetrics(consumerMetrics);
} catch (err) {
logger.error({ err }, 'Error inserting consumer metrics');
}
}
}
private async publishStreamMetrics(metrics: StreamMetrics[]): Promise<void> {
try {
for (const metric of metrics) {
await this.redis.publish(
METRICS_CHANNEL,
JSON.stringify({
type: 'stream',
channel: `stream:${metric.clusterId}:${metric.streamName}`,
data: {
clusterId: metric.clusterId,
streamName: metric.streamName,
timestamp: metric.timestamp.toISOString(),
messagesTotal: metric.messagesTotal,
bytesTotal: metric.bytesTotal,
messagesRate: metric.messagesRate,
bytesRate: metric.bytesRate,
consumerCount: metric.consumerCount,
},
})
);
}
} catch (err) {
logger.error({ err }, 'Error publishing stream metrics to Redis');
}
}
private async publishConsumerMetrics(metrics: ConsumerMetrics[]): Promise<void> {
try {
for (const metric of metrics) {
await this.redis.publish(
METRICS_CHANNEL,
JSON.stringify({
type: 'consumer',
channel: `consumer:${metric.clusterId}:${metric.streamName}:${metric.consumerName}`,
data: {
clusterId: metric.clusterId,
streamName: metric.streamName,
consumerName: metric.consumerName,
timestamp: metric.timestamp.toISOString(),
pendingCount: metric.pendingCount,
ackPending: metric.ackPending,
lag: metric.lag,
deliveredRate: metric.deliveredRate,
ackRate: metric.ackRate,
},
})
);
}
} catch (err) {
logger.error({ err }, 'Error publishing consumer metrics to Redis');
}
}
private async collectClusterMetrics(): Promise<void> {
const clusterMetrics: ClusterMetrics[] = [];
const timestamp = new Date();
+42
View File
@@ -1,9 +1,12 @@
import { PrismaClient, IncidentStatus, NotificationChannelType } from '@prisma/client';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import Redis from 'ioredis';
import pino from 'pino';
import { config } from '../config';
import type { AlertSeverity } from '../../../shared/src/index';
const ALERTS_CHANNEL = 'alerts';
const logger = pino({ name: 'alert-processor' });
interface AlertRule {
@@ -44,6 +47,7 @@ interface AlertState {
export class AlertProcessor {
private prisma: PrismaClient;
private clickhouse: ClickHouseClient;
private redis: Redis;
private alertStates: Map<string, AlertState> = new Map();
private processInterval: NodeJS.Timeout | null = null;
@@ -55,6 +59,7 @@ export class AlertProcessor {
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
this.redis = new Redis(config.REDIS_URL);
}
async start(): Promise<void> {
@@ -77,6 +82,7 @@ export class AlertProcessor {
}
await this.clickhouse.close();
await this.redis.quit();
await this.prisma.$disconnect();
logger.info('Alert processor stopped');
@@ -307,6 +313,21 @@ export class AlertProcessor {
where: { id: incident.id },
data: { notifiedAt: new Date() },
});
// Broadcast to WebSocket clients via Redis
await this.broadcastAlert({
type: 'incident_created',
incident: {
id: incident.id,
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
status: 'open',
metricValue,
threshold: rule.threshold.value,
triggeredAt: new Date().toISOString(),
},
});
}
private async resolveAlert(rule: AlertRule, incidentId: string): Promise<void> {
@@ -351,6 +372,27 @@ export class AlertProcessor {
for (const channel of enabledChannels) {
await this.sendNotification(channel, rule, 0, 'resolved', incidentId);
}
// Broadcast to WebSocket clients via Redis
await this.broadcastAlert({
type: 'incident_resolved',
incident: {
id: incidentId,
ruleId: rule.id,
ruleName: rule.name,
status: 'resolved',
resolvedAt: new Date().toISOString(),
},
});
}
private async broadcastAlert(data: Record<string, unknown>): Promise<void> {
try {
await this.redis.publish(ALERTS_CHANNEL, JSON.stringify(data));
logger.debug({ type: data.type }, 'Alert broadcasted to WebSocket clients');
} catch (err) {
logger.error({ err }, 'Error broadcasting alert to Redis');
}
}
private async sendNotification(