Skip to content

Commit d46ca19

Browse files
authored
Merge pull request #268 from Smartdevs17/feat/monitoring-alerting-229-fixed
feat: implement subscription contract monitoring and alerting
2 parents 59b8ee6 + cbbca8d commit d46ca19

5 files changed

Lines changed: 476 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { AlertingService, createDispatcher } from '../alerting';
2+
import type { Alert, AlertChannelConfig } from '../types';
3+
4+
const makeAlert = (overrides: Partial<Alert> = {}): Alert => ({
5+
id: 'alert-1',
6+
severity: 'critical',
7+
title: 'Test Alert',
8+
message: 'Something went wrong',
9+
timestamp: Date.now(),
10+
resolved: false,
11+
ruleId: 'test-rule',
12+
...overrides,
13+
});
14+
15+
describe('AlertingService', () => {
16+
it('dispatches to console channel without throwing', async () => {
17+
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
18+
const svc = new AlertingService([{ type: 'console' }]);
19+
await svc.dispatch(makeAlert());
20+
expect(spy).toHaveBeenCalled();
21+
spy.mockRestore();
22+
});
23+
24+
it('is idempotent — same alert dispatched only once', async () => {
25+
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
26+
const svc = new AlertingService([{ type: 'console' }]);
27+
const alert = makeAlert();
28+
await svc.dispatch(alert);
29+
await svc.dispatch(alert);
30+
expect(spy).toHaveBeenCalledTimes(1);
31+
spy.mockRestore();
32+
});
33+
34+
it('dispatchAll skips resolved alerts', async () => {
35+
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
36+
const svc = new AlertingService([{ type: 'console' }]);
37+
await svc.dispatchAll([
38+
makeAlert({ id: 'a1', resolved: false }),
39+
makeAlert({ id: 'a2', resolved: true }),
40+
]);
41+
expect(spy).toHaveBeenCalledTimes(1);
42+
spy.mockRestore();
43+
});
44+
45+
it('addChannel adds a new dispatcher', async () => {
46+
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
47+
const svc = new AlertingService([]);
48+
svc.addChannel({ type: 'console' });
49+
await svc.dispatch(makeAlert({ id: 'new-alert' }));
50+
expect(spy).toHaveBeenCalled();
51+
spy.mockRestore();
52+
});
53+
54+
it('createDispatcher throws when webhookUrl is missing for slack', () => {
55+
const config: AlertChannelConfig = { type: 'slack' };
56+
expect(() => createDispatcher(config)).toThrow('webhookUrl required');
57+
});
58+
59+
it('createDispatcher throws when webhookUrl is missing for pagerduty', () => {
60+
const config: AlertChannelConfig = { type: 'pagerduty' };
61+
expect(() => createDispatcher(config)).toThrow('webhookUrl required');
62+
});
63+
64+
it('dispatches to webhook channel (slack) via fetch', async () => {
65+
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
66+
global.fetch = mockFetch;
67+
68+
const svc = new AlertingService([
69+
{ type: 'slack', webhookUrl: 'https://hooks.slack.com/test' },
70+
]);
71+
await svc.dispatch(makeAlert({ id: 'slack-alert' }));
72+
73+
expect(mockFetch).toHaveBeenCalledWith(
74+
'https://hooks.slack.com/test',
75+
expect.objectContaining({ method: 'POST' })
76+
);
77+
});
78+
79+
it('dispatches to webhook channel (pagerduty) via fetch', async () => {
80+
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
81+
global.fetch = mockFetch;
82+
83+
const svc = new AlertingService([
84+
{ type: 'pagerduty', webhookUrl: 'https://events.pagerduty.com/v2/enqueue' },
85+
]);
86+
await svc.dispatch(makeAlert({ id: 'pd-alert' }));
87+
88+
expect(mockFetch).toHaveBeenCalledWith(
89+
'https://events.pagerduty.com/v2/enqueue',
90+
expect.objectContaining({ method: 'POST' })
91+
);
92+
});
93+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { MonitoringService } from '../monitoring';
2+
import type { TransactionEvent } from '../types';
3+
4+
const makeEvent = (
5+
status: TransactionEvent['status'],
6+
gasUsed?: number,
7+
id = Math.random().toString(36)
8+
): TransactionEvent => ({
9+
id,
10+
subscriptionId: 'sub-1',
11+
amount: 10,
12+
currency: 'USD',
13+
status,
14+
timestamp: Date.now(),
15+
gasUsed,
16+
});
17+
18+
describe('MonitoringService', () => {
19+
let svc: MonitoringService;
20+
beforeEach(() => {
21+
svc = new MonitoringService();
22+
});
23+
24+
// ── Transaction recording ─────────────────────────────────────────────────
25+
26+
it('records transactions and reflects them in dashboard', () => {
27+
svc.recordTransaction(makeEvent('success'));
28+
svc.recordTransaction(makeEvent('success'));
29+
const dash = svc.getDashboard();
30+
expect(dash.totalTransactions).toBe(2);
31+
expect(dash.failureCount).toBe(0);
32+
expect(dash.successRate).toBe(1);
33+
});
34+
35+
it('tracks failed transactions', () => {
36+
svc.recordTransaction(makeEvent('success'));
37+
svc.recordTransaction(makeEvent('failed'));
38+
const dash = svc.getDashboard();
39+
expect(dash.failureCount).toBe(1);
40+
expect(dash.successRate).toBe(0.5);
41+
});
42+
43+
it('computes average gas used', () => {
44+
svc.recordTransaction(makeEvent('success', 100_000));
45+
svc.recordTransaction(makeEvent('success', 300_000));
46+
expect(svc.getDashboard().avgGasUsed).toBe(200_000);
47+
});
48+
49+
// ── Anomaly detection ─────────────────────────────────────────────────────
50+
51+
it('raises critical alert when failure rate exceeds 30 %', () => {
52+
// 4 failures out of 5 = 80 %
53+
for (let i = 0; i < 4; i++) svc.recordTransaction(makeEvent('failed'));
54+
svc.recordTransaction(makeEvent('success'));
55+
const alerts = svc.getActiveAlerts();
56+
expect(alerts.some((a) => a.ruleId === 'high-failure-rate')).toBe(true);
57+
expect(alerts.find((a) => a.ruleId === 'high-failure-rate')?.severity).toBe('critical');
58+
});
59+
60+
it('raises warning alert when avg gas exceeds 500 000', () => {
61+
svc.recordTransaction(makeEvent('success', 600_000));
62+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'gas-spike')).toBe(true);
63+
});
64+
65+
it('does not raise duplicate alerts for the same open rule', () => {
66+
for (let i = 0; i < 6; i++) svc.recordTransaction(makeEvent('failed'));
67+
const alerts = svc.getActiveAlerts().filter((a) => a.ruleId === 'high-failure-rate');
68+
expect(alerts).toHaveLength(1);
69+
});
70+
71+
it('does not alert when failure rate is below threshold', () => {
72+
svc.recordTransaction(makeEvent('success'));
73+
svc.recordTransaction(makeEvent('success'));
74+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'high-failure-rate')).toBe(false);
75+
});
76+
77+
// ── Alert resolution ──────────────────────────────────────────────────────
78+
79+
it('resolves an alert by id', () => {
80+
for (let i = 0; i < 4; i++) svc.recordTransaction(makeEvent('failed'));
81+
svc.recordTransaction(makeEvent('success'));
82+
const alert = svc.getActiveAlerts().find((a) => a.ruleId === 'high-failure-rate')!;
83+
svc.resolveAlert(alert.id);
84+
expect(svc.getActiveAlerts().some((a) => a.id === alert.id)).toBe(false);
85+
});
86+
87+
// ── Custom rules ──────────────────────────────────────────────────────────
88+
89+
it('supports adding a custom alert rule', () => {
90+
svc.addRule({
91+
id: 'custom-rule',
92+
name: 'Custom Rule',
93+
severity: 'info',
94+
message: 'Custom triggered',
95+
evaluate: () => true,
96+
});
97+
svc.recordTransaction(makeEvent('success'));
98+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'custom-rule')).toBe(true);
99+
});
100+
101+
it('supports removing a rule', () => {
102+
svc.removeRule('gas-spike');
103+
svc.recordTransaction(makeEvent('success', 999_999));
104+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'gas-spike')).toBe(false);
105+
});
106+
107+
// ── Dashboard ─────────────────────────────────────────────────────────────
108+
109+
it('dashboard returns empty state when no events recorded', () => {
110+
const dash = svc.getDashboard();
111+
expect(dash.totalTransactions).toBe(0);
112+
expect(dash.successRate).toBe(1);
113+
expect(dash.activeAlerts).toHaveLength(0);
114+
});
115+
});

backend/services/alerting.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Alerting service — dispatches alerts to Slack, PagerDuty, or console.
3+
* Channels are pluggable; add as many as needed.
4+
*/
5+
6+
import type { Alert, AlertChannelConfig } from './types';
7+
8+
export interface AlertDispatcher {
9+
send(alert: Alert): Promise<void>;
10+
}
11+
12+
// ── Channel implementations ───────────────────────────────────────────────────
13+
14+
class ConsoleDispatcher implements AlertDispatcher {
15+
async send(alert: Alert): Promise<void> {
16+
const prefix =
17+
alert.severity === 'critical' ? '🚨' : alert.severity === 'warning' ? '⚠️' : 'ℹ️';
18+
console.log(`${prefix} [${alert.severity.toUpperCase()}] ${alert.title}: ${alert.message}`);
19+
}
20+
}
21+
22+
class WebhookDispatcher implements AlertDispatcher {
23+
constructor(
24+
private readonly url: string,
25+
private readonly type: 'slack' | 'pagerduty'
26+
) {}
27+
28+
async send(alert: Alert): Promise<void> {
29+
const body =
30+
this.type === 'slack'
31+
? JSON.stringify({
32+
text: `*[${alert.severity.toUpperCase()}] ${alert.title}*\n${alert.message}`,
33+
})
34+
: JSON.stringify({
35+
routing_key: '', // populated from env in production
36+
event_action: alert.severity === 'critical' ? 'trigger' : 'acknowledge',
37+
payload: {
38+
summary: alert.title,
39+
severity: alert.severity,
40+
source: 'SubTrackr',
41+
custom_details: { message: alert.message, timestamp: alert.timestamp },
42+
},
43+
});
44+
45+
await fetch(this.url, {
46+
method: 'POST',
47+
headers: { 'Content-Type': 'application/json' },
48+
body,
49+
});
50+
}
51+
}
52+
53+
// ── Factory ───────────────────────────────────────────────────────────────────
54+
55+
export function createDispatcher(config: AlertChannelConfig): AlertDispatcher {
56+
if (config.type === 'console') return new ConsoleDispatcher();
57+
if (!config.webhookUrl) throw new Error(`webhookUrl required for channel type "${config.type}"`);
58+
return new WebhookDispatcher(config.webhookUrl, config.type);
59+
}
60+
61+
// ── Alerting service ──────────────────────────────────────────────────────────
62+
63+
export class AlertingService {
64+
private dispatchers: AlertDispatcher[] = [];
65+
private sent = new Set<string>();
66+
67+
constructor(channels: AlertChannelConfig[] = [{ type: 'console' }]) {
68+
this.dispatchers = channels.map(createDispatcher);
69+
}
70+
71+
addChannel(config: AlertChannelConfig): void {
72+
this.dispatchers.push(createDispatcher(config));
73+
}
74+
75+
/** Dispatch an alert to all channels (idempotent — same alert id sent only once) */
76+
async dispatch(alert: Alert): Promise<void> {
77+
if (this.sent.has(alert.id)) return;
78+
this.sent.add(alert.id);
79+
await Promise.all(this.dispatchers.map((d) => d.send(alert)));
80+
}
81+
82+
/** Dispatch all unresolved alerts from a list */
83+
async dispatchAll(alerts: Alert[]): Promise<void> {
84+
await Promise.all(alerts.filter((a) => !a.resolved).map((a) => this.dispatch(a)));
85+
}
86+
}

0 commit comments

Comments
 (0)