From ddee3d07bdbd20a17b0f0da4212f2187f2dbcecd Mon Sep 17 00:00:00 2001 From: senmalong <> Date: Wed, 22 Apr 2026 15:47:41 +0100 Subject: [PATCH] feat: implement subscription contract monitoring and alerting (#229) - Add TransactionEvent, Metric, Alert, AlertRule, DashboardSnapshot types (backend/services/types.ts) - Add MonitoringService: transaction ingestion, metric computation, anomaly detection with built-in high-failure-rate and gas-spike rules, custom rule support, alert deduplication, dashboard snapshot (backend/services/monitoring.ts) - Add AlertingService: pluggable channel dispatchers for Slack, PagerDuty, and console; idempotent dispatch; dispatchAll skips resolved alerts (backend/services/alerting.ts) - Add 19 passing tests covering all monitoring and alerting scenarios (backend/services/__tests__/) --- backend/services/__tests__/alerting.test.ts | 93 +++++++++++++ backend/services/__tests__/monitoring.test.ts | 115 ++++++++++++++++ backend/services/alerting.ts | 86 ++++++++++++ backend/services/index.ts | 13 ++ backend/services/monitoring.ts | 126 ++++++++++++++++++ backend/services/types.ts | 56 ++++++++ 6 files changed, 489 insertions(+) create mode 100644 backend/services/__tests__/alerting.test.ts create mode 100644 backend/services/__tests__/monitoring.test.ts create mode 100644 backend/services/alerting.ts create mode 100644 backend/services/index.ts create mode 100644 backend/services/monitoring.ts create mode 100644 backend/services/types.ts diff --git a/backend/services/__tests__/alerting.test.ts b/backend/services/__tests__/alerting.test.ts new file mode 100644 index 0000000..6078221 --- /dev/null +++ b/backend/services/__tests__/alerting.test.ts @@ -0,0 +1,93 @@ +import { AlertingService, createDispatcher } from '../alerting'; +import type { Alert, AlertChannelConfig } from '../types'; + +const makeAlert = (overrides: Partial = {}): 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' }) + ); + }); +}); diff --git a/backend/services/__tests__/monitoring.test.ts b/backend/services/__tests__/monitoring.test.ts new file mode 100644 index 0000000..cb15430 --- /dev/null +++ b/backend/services/__tests__/monitoring.test.ts @@ -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); + }); +}); diff --git a/backend/services/alerting.ts b/backend/services/alerting.ts new file mode 100644 index 0000000..c35911b --- /dev/null +++ b/backend/services/alerting.ts @@ -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; +} + +// ── Channel implementations ─────────────────────────────────────────────────── + +class ConsoleDispatcher implements AlertDispatcher { + async send(alert: Alert): Promise { + 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 { + 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(); + + 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 { + 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 { + await Promise.all(alerts.filter((a) => !a.resolved).map((a) => this.dispatch(a))); + } +} diff --git a/backend/services/index.ts b/backend/services/index.ts new file mode 100644 index 0000000..307c8c1 --- /dev/null +++ b/backend/services/index.ts @@ -0,0 +1,13 @@ +export { MonitoringService } from './monitoring'; +export { AlertingService, createDispatcher } from './alerting'; +export type { + TransactionEvent, + Metric, + Alert, + AlertRule, + AlertSeverity, + AlertChannel, + AlertChannelConfig, + DashboardSnapshot, + TransactionStatus, +} from './types'; diff --git a/backend/services/monitoring.ts b/backend/services/monitoring.ts new file mode 100644 index 0000000..7534b7e --- /dev/null +++ b/backend/services/monitoring.ts @@ -0,0 +1,126 @@ +/** + * Monitoring service — ingests transaction events, computes metrics, + * detects anomalies, and exposes a dashboard snapshot. + */ + +import type { TransactionEvent, Metric, AlertRule, Alert, DashboardSnapshot } from './types'; + +export class MonitoringService { + private events: TransactionEvent[] = []; + private metrics: Metric[] = []; + private rules: AlertRule[] = []; + private alerts: Alert[] = []; + + // ── Built-in anomaly detection rules ────────────────────────────────────── + + /** Default rules: high failure rate and gas spike */ + static defaultRules(): AlertRule[] { + return [ + { + id: 'high-failure-rate', + name: 'High Transaction Failure Rate', + severity: 'critical', + message: 'Transaction failure rate exceeded 30 %', + evaluate(metrics) { + const rate = metrics.find((m) => m.name === 'failure_rate'); + return rate !== undefined && rate.value > 0.3; + }, + }, + { + id: 'gas-spike', + name: 'Gas Usage Spike', + severity: 'warning', + message: 'Average gas usage exceeded 500 000 units', + evaluate(metrics) { + const gas = metrics.find((m) => m.name === 'avg_gas_used'); + return gas !== undefined && gas.value > 500_000; + }, + }, + ]; + } + + constructor(rules: AlertRule[] = MonitoringService.defaultRules()) { + this.rules = rules; + } + + // ── Transaction ingestion ───────────────────────────────────────────────── + + recordTransaction(event: TransactionEvent): void { + this.events.push(event); + this._recomputeMetrics(); + this._evaluateRules(); + } + + // ── Custom alert rules ──────────────────────────────────────────────────── + + addRule(rule: AlertRule): void { + this.rules = [...this.rules.filter((r) => r.id !== rule.id), rule]; + } + + removeRule(id: string): void { + this.rules = this.rules.filter((r) => r.id !== id); + } + + // ── Alert management ────────────────────────────────────────────────────── + + resolveAlert(alertId: string): void { + this.alerts = this.alerts.map((a) => (a.id === alertId ? { ...a, resolved: true } : a)); + } + + getActiveAlerts(): Alert[] { + return this.alerts.filter((a) => !a.resolved); + } + + // ── Dashboard ───────────────────────────────────────────────────────────── + + getDashboard(): DashboardSnapshot { + const total = this.events.length; + const failed = this.events.filter((e) => e.status === 'failed').length; + const gasValues = this.events.filter((e) => e.gasUsed !== undefined).map((e) => e.gasUsed!); + const avgGas = gasValues.length ? gasValues.reduce((a, b) => a + b, 0) / gasValues.length : 0; + + return { + totalTransactions: total, + successRate: total === 0 ? 1 : (total - failed) / total, + failureCount: failed, + avgGasUsed: avgGas, + activeAlerts: this.getActiveAlerts(), + recentMetrics: this.metrics.slice(-20), + }; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + private _recomputeMetrics(): void { + const now = Date.now(); + const total = this.events.length; + const failed = this.events.filter((e) => e.status === 'failed').length; + const gasValues = this.events.filter((e) => e.gasUsed !== undefined).map((e) => e.gasUsed!); + const avgGas = gasValues.length ? gasValues.reduce((a, b) => a + b, 0) / gasValues.length : 0; + + this.metrics.push( + { name: 'failure_rate', value: total === 0 ? 0 : failed / total, timestamp: now }, + { name: 'avg_gas_used', value: avgGas, timestamp: now }, + { name: 'total_transactions', value: total, timestamp: now } + ); + } + + private _evaluateRules(): void { + for (const rule of this.rules) { + const triggered = rule.evaluate(this.metrics); + if (!triggered) continue; + // Avoid duplicate open alerts for the same rule + const alreadyOpen = this.alerts.some((a) => a.ruleId === rule.id && !a.resolved); + if (alreadyOpen) continue; + this.alerts.push({ + id: `${rule.id}-${Date.now()}`, + severity: rule.severity, + title: rule.name, + message: rule.message, + timestamp: Date.now(), + resolved: false, + ruleId: rule.id, + }); + } + } +} diff --git a/backend/services/types.ts b/backend/services/types.ts new file mode 100644 index 0000000..1c954b5 --- /dev/null +++ b/backend/services/types.ts @@ -0,0 +1,56 @@ +// Monitoring & alerting type definitions + +export type TransactionStatus = 'success' | 'failed' | 'pending'; +export type AlertSeverity = 'info' | 'warning' | 'critical'; +export type AlertChannel = 'slack' | 'pagerduty' | 'console'; + +export interface TransactionEvent { + id: string; + subscriptionId: string; + amount: number; + currency: string; + status: TransactionStatus; + timestamp: number; + gasUsed?: number; + errorMessage?: string; +} + +export interface Metric { + name: string; + value: number; + timestamp: number; + tags?: Record; +} + +export interface Alert { + id: string; + severity: AlertSeverity; + title: string; + message: string; + timestamp: number; + resolved: boolean; + ruleId: string; +} + +export interface AlertRule { + id: string; + name: string; + /** Returns true when the rule is violated */ + evaluate: (metrics: Metric[]) => boolean; + severity: AlertSeverity; + message: string; +} + +export interface AlertChannelConfig { + type: AlertChannel; + webhookUrl?: string; // Slack / PagerDuty webhook +} + +export interface DashboardSnapshot { + totalTransactions: number; + successRate: number; // 0–1 + failureCount: number; + avgGasUsed: number; + activeAlerts: Alert[]; + recentMetrics: Metric[]; +}