Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions backend/services/__tests__/alerting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AlertingService, createDispatcher } from '../alerting';
import type { Alert, AlertChannelConfig } from '../types';

const makeAlert = (overrides: Partial<Alert> = {}): Alert => ({
id: 'alert-1',
severity: 'critical',
title: 'Test Alert',
message: 'Something went wrong',
timestamp: Date.now(),
resolved: false,
ruleId: 'test-rule',
...overrides,
});

describe('AlertingService', () => {
it('dispatches to console channel without throwing', async () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
const svc = new AlertingService([{ type: 'console' }]);
await svc.dispatch(makeAlert());
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});

it('is idempotent — same alert dispatched only once', async () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
const svc = new AlertingService([{ type: 'console' }]);
const alert = makeAlert();
await svc.dispatch(alert);
await svc.dispatch(alert);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});

it('dispatchAll skips resolved alerts', async () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
const svc = new AlertingService([{ type: 'console' }]);
await svc.dispatchAll([
makeAlert({ id: 'a1', resolved: false }),
makeAlert({ id: 'a2', resolved: true }),
]);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});

it('addChannel adds a new dispatcher', async () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
const svc = new AlertingService([]);
svc.addChannel({ type: 'console' });
await svc.dispatch(makeAlert({ id: 'new-alert' }));
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});

it('createDispatcher throws when webhookUrl is missing for slack', () => {
const config: AlertChannelConfig = { type: 'slack' };
expect(() => createDispatcher(config)).toThrow('webhookUrl required');
});

it('createDispatcher throws when webhookUrl is missing for pagerduty', () => {
const config: AlertChannelConfig = { type: 'pagerduty' };
expect(() => createDispatcher(config)).toThrow('webhookUrl required');
});

it('dispatches to webhook channel (slack) via fetch', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch;

const svc = new AlertingService([
{ type: 'slack', webhookUrl: 'https://hooks.slack.com/test' },
]);
await svc.dispatch(makeAlert({ id: 'slack-alert' }));

expect(mockFetch).toHaveBeenCalledWith(
'https://hooks.slack.com/test',
expect.objectContaining({ method: 'POST' })
);
});

it('dispatches to webhook channel (pagerduty) via fetch', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch;

const svc = new AlertingService([
{ type: 'pagerduty', webhookUrl: 'https://events.pagerduty.com/v2/enqueue' },
]);
await svc.dispatch(makeAlert({ id: 'pd-alert' }));

expect(mockFetch).toHaveBeenCalledWith(
'https://events.pagerduty.com/v2/enqueue',
expect.objectContaining({ method: 'POST' })
);
});
});
115 changes: 115 additions & 0 deletions backend/services/__tests__/monitoring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { MonitoringService } from '../monitoring';
import type { TransactionEvent } from '../types';

const makeEvent = (
status: TransactionEvent['status'],
gasUsed?: number,
id = Math.random().toString(36)
): TransactionEvent => ({
id,
subscriptionId: 'sub-1',
amount: 10,
currency: 'USD',
status,
timestamp: Date.now(),
gasUsed,
});

describe('MonitoringService', () => {
let svc: MonitoringService;
beforeEach(() => {
svc = new MonitoringService();
});

// ── Transaction recording ─────────────────────────────────────────────────

it('records transactions and reflects them in dashboard', () => {
svc.recordTransaction(makeEvent('success'));
svc.recordTransaction(makeEvent('success'));
const dash = svc.getDashboard();
expect(dash.totalTransactions).toBe(2);
expect(dash.failureCount).toBe(0);
expect(dash.successRate).toBe(1);
});

it('tracks failed transactions', () => {
svc.recordTransaction(makeEvent('success'));
svc.recordTransaction(makeEvent('failed'));
const dash = svc.getDashboard();
expect(dash.failureCount).toBe(1);
expect(dash.successRate).toBe(0.5);
});

it('computes average gas used', () => {
svc.recordTransaction(makeEvent('success', 100_000));
svc.recordTransaction(makeEvent('success', 300_000));
expect(svc.getDashboard().avgGasUsed).toBe(200_000);
});

// ── Anomaly detection ─────────────────────────────────────────────────────

it('raises critical alert when failure rate exceeds 30 %', () => {
// 4 failures out of 5 = 80 %
for (let i = 0; i < 4; i++) svc.recordTransaction(makeEvent('failed'));
svc.recordTransaction(makeEvent('success'));
const alerts = svc.getActiveAlerts();
expect(alerts.some((a) => a.ruleId === 'high-failure-rate')).toBe(true);
expect(alerts.find((a) => a.ruleId === 'high-failure-rate')?.severity).toBe('critical');
});

it('raises warning alert when avg gas exceeds 500 000', () => {
svc.recordTransaction(makeEvent('success', 600_000));
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'gas-spike')).toBe(true);
});

it('does not raise duplicate alerts for the same open rule', () => {
for (let i = 0; i < 6; i++) svc.recordTransaction(makeEvent('failed'));
const alerts = svc.getActiveAlerts().filter((a) => a.ruleId === 'high-failure-rate');
expect(alerts).toHaveLength(1);
});

it('does not alert when failure rate is below threshold', () => {
svc.recordTransaction(makeEvent('success'));
svc.recordTransaction(makeEvent('success'));
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'high-failure-rate')).toBe(false);
});

// ── Alert resolution ──────────────────────────────────────────────────────

it('resolves an alert by id', () => {
for (let i = 0; i < 4; i++) svc.recordTransaction(makeEvent('failed'));
svc.recordTransaction(makeEvent('success'));
const alert = svc.getActiveAlerts().find((a) => a.ruleId === 'high-failure-rate')!;
svc.resolveAlert(alert.id);
expect(svc.getActiveAlerts().some((a) => a.id === alert.id)).toBe(false);
});

// ── Custom rules ──────────────────────────────────────────────────────────

it('supports adding a custom alert rule', () => {
svc.addRule({
id: 'custom-rule',
name: 'Custom Rule',
severity: 'info',
message: 'Custom triggered',
evaluate: () => true,
});
svc.recordTransaction(makeEvent('success'));
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'custom-rule')).toBe(true);
});

it('supports removing a rule', () => {
svc.removeRule('gas-spike');
svc.recordTransaction(makeEvent('success', 999_999));
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'gas-spike')).toBe(false);
});

// ── Dashboard ─────────────────────────────────────────────────────────────

it('dashboard returns empty state when no events recorded', () => {
const dash = svc.getDashboard();
expect(dash.totalTransactions).toBe(0);
expect(dash.successRate).toBe(1);
expect(dash.activeAlerts).toHaveLength(0);
});
});
86 changes: 86 additions & 0 deletions backend/services/alerting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Alerting service — dispatches alerts to Slack, PagerDuty, or console.
* Channels are pluggable; add as many as needed.
*/

import type { Alert, AlertChannelConfig } from './types';

export interface AlertDispatcher {
send(alert: Alert): Promise<void>;
}

// ── Channel implementations ───────────────────────────────────────────────────

class ConsoleDispatcher implements AlertDispatcher {
async send(alert: Alert): Promise<void> {
const prefix =
alert.severity === 'critical' ? '🚨' : alert.severity === 'warning' ? '⚠️' : 'ℹ️';
console.log(`${prefix} [${alert.severity.toUpperCase()}] ${alert.title}: ${alert.message}`);
}
}

class WebhookDispatcher implements AlertDispatcher {
constructor(
private readonly url: string,
private readonly type: 'slack' | 'pagerduty'
) {}

async send(alert: Alert): Promise<void> {
const body =
this.type === 'slack'
? JSON.stringify({
text: `*[${alert.severity.toUpperCase()}] ${alert.title}*\n${alert.message}`,
})
: JSON.stringify({
routing_key: '', // populated from env in production
event_action: alert.severity === 'critical' ? 'trigger' : 'acknowledge',
payload: {
summary: alert.title,
severity: alert.severity,
source: 'SubTrackr',
custom_details: { message: alert.message, timestamp: alert.timestamp },
},
});

await fetch(this.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
}
}

// ── Factory ───────────────────────────────────────────────────────────────────

export function createDispatcher(config: AlertChannelConfig): AlertDispatcher {
if (config.type === 'console') return new ConsoleDispatcher();
if (!config.webhookUrl) throw new Error(`webhookUrl required for channel type "${config.type}"`);
return new WebhookDispatcher(config.webhookUrl, config.type);
}

// ── Alerting service ──────────────────────────────────────────────────────────

export class AlertingService {
private dispatchers: AlertDispatcher[] = [];
private sent = new Set<string>();

constructor(channels: AlertChannelConfig[] = [{ type: 'console' }]) {
this.dispatchers = channels.map(createDispatcher);
}

addChannel(config: AlertChannelConfig): void {
this.dispatchers.push(createDispatcher(config));
}

/** Dispatch an alert to all channels (idempotent — same alert id sent only once) */
async dispatch(alert: Alert): Promise<void> {
if (this.sent.has(alert.id)) return;
this.sent.add(alert.id);
await Promise.all(this.dispatchers.map((d) => d.send(alert)));
}

/** Dispatch all unresolved alerts from a list */
async dispatchAll(alerts: Alert[]): Promise<void> {
await Promise.all(alerts.filter((a) => !a.resolved).map((a) => this.dispatch(a)));
}
}
Loading
Loading