Skip to content

Commit b05fdb4

Browse files
feat(sla): add uptime monitoring breach detection with credits
Add SLA target configuration, breach detection, auto-resolution and credit issuance to the shared MonitoringService, with new types/exports, a transaction health dashboard hook, unit + benchmark + integration tests and docs. Extend the theme system with export/import, light/dark variant pairs and accessibility/CSS-variable derivation; add SlaDashboard tests and expo test mocks. Clean up mangled duplicate code in CancellationFlowScreen, supportStore and fraud types, and align exportTheme with its overloaded type. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent f172723 commit b05fdb4

28 files changed

Lines changed: 2228 additions & 99 deletions

backend/services/shared/__tests__/monitoring.test.ts

Lines changed: 299 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MonitoringService } from '../monitoring';
1+
import { MonitoringService, calculateSlaCreditAmount } from '../monitoring';
22
import type { TransactionEvent } from '../types';
33

44
const makeEvent = (
@@ -15,6 +15,21 @@ const makeEvent = (
1515
gasUsed,
1616
});
1717

18+
const makeSlaEvent = (
19+
subscriptionId: string,
20+
status: TransactionEvent['status'],
21+
timestamp: number = Date.now(),
22+
id = Math.random().toString(36)
23+
): TransactionEvent => ({
24+
id,
25+
subscriptionId,
26+
amount: 10,
27+
currency: 'USD',
28+
status,
29+
timestamp,
30+
gasUsed: 100_000,
31+
});
32+
1833
describe('MonitoringService', () => {
1934
let svc: MonitoringService;
2035
beforeEach(() => {
@@ -112,4 +127,287 @@ describe('MonitoringService', () => {
112127
expect(dash.successRate).toBe(1);
113128
expect(dash.activeAlerts).toHaveLength(0);
114129
});
130+
131+
// ── SLA target configuration ──────────────────────────────────────────────
132+
133+
describe('SLA monitoring — target configuration', () => {
134+
it('registers an SLA target and reports a compliant initial status', () => {
135+
svc.setSlaTarget('sub-sla', { uptimeTarget: 99, measurementInterval: 86_400 });
136+
const status = svc.getSlaStatus('sub-sla');
137+
expect(status).not.toBeNull();
138+
expect(status!.uptimeTarget).toBe(99);
139+
expect(status!.uptimePercentage).toBe(100);
140+
expect(status!.compliant).toBe(true);
141+
expect(status!.observedTransactions).toBe(0);
142+
expect(svc.getSlaBreaches('sub-sla')).toHaveLength(0);
143+
});
144+
145+
it('normalizes invalid target values', () => {
146+
svc.setSlaTarget('sub-bad', {
147+
uptimeTarget: Number.NaN,
148+
measurementInterval: -50,
149+
creditCap: -3,
150+
});
151+
const target = svc.getSlaTarget('sub-bad');
152+
expect(target).toEqual({
153+
uptimeTarget: 99,
154+
measurementInterval: 1,
155+
creditCap: 0,
156+
});
157+
});
158+
159+
it('clamps uptime target into 0–100', () => {
160+
svc.setSlaTarget('sub-clamp', { uptimeTarget: 150, measurementInterval: 60 });
161+
expect(svc.getSlaTarget('sub-clamp')!.uptimeTarget).toBe(100);
162+
});
163+
164+
it('updates an existing target in place', () => {
165+
svc.setSlaTarget('sub-upd', { uptimeTarget: 99, measurementInterval: 60 });
166+
svc.setSlaTarget('sub-upd', { uptimeTarget: 99.9, measurementInterval: 120 });
167+
expect(svc.getSlaTarget('sub-upd')).toEqual({
168+
uptimeTarget: 99.9,
169+
measurementInterval: 120,
170+
creditCap: 0,
171+
});
172+
});
173+
174+
it('returns null status and no breaches after removeSlaTarget', () => {
175+
svc.setSlaTarget('sub-rm', { uptimeTarget: 99, measurementInterval: 86_400 });
176+
svc.recordTransaction(makeSlaEvent('sub-rm', 'failed'));
177+
expect(svc.getSlaBreaches('sub-rm')).toHaveLength(1);
178+
179+
svc.removeSlaTarget('sub-rm');
180+
expect(svc.getSlaStatus('sub-rm')).toBeNull();
181+
expect(svc.getSlaTarget('sub-rm')).toBeUndefined();
182+
// Breach history is retained; no open alert remains for the subscription.
183+
expect(svc.getSlaBreaches('sub-rm')).toHaveLength(1);
184+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'sla-breach:sub-rm')).toBe(false);
185+
});
186+
});
187+
188+
// ── SLA breach detection ──────────────────────────────────────────────────
189+
190+
describe('SLA monitoring — breach detection', () => {
191+
it('detects a breach when uptime drops below target and issues credit', () => {
192+
svc.setSlaTarget('sub-breach', { uptimeTarget: 99, measurementInterval: 86_400 });
193+
svc.recordTransaction(makeSlaEvent('sub-breach', 'success'));
194+
svc.recordTransaction(makeSlaEvent('sub-breach', 'failed'));
195+
196+
const breaches = svc.getSlaBreaches('sub-breach');
197+
expect(breaches).toHaveLength(1);
198+
expect(breaches[0].resolvedAt).toBeNull();
199+
expect(breaches[0].uptimePercentage).toBe(50);
200+
expect(breaches[0].observedTransactions).toBe(2);
201+
expect(breaches[0].failedTransactions).toBe(1);
202+
expect(breaches[0].creditAmount).toBeGreaterThan(0);
203+
204+
const status = svc.getSlaStatus('sub-breach');
205+
expect(status!.compliant).toBe(false);
206+
expect(status!.activeBreachId).toBe(breaches[0].id);
207+
expect(status!.creditBalance).toBe(breaches[0].creditAmount);
208+
});
209+
210+
it('raises an SLA alert alongside the breach', () => {
211+
svc.setSlaTarget('sub-alert', { uptimeTarget: 99, measurementInterval: 86_400 });
212+
svc.recordTransaction(makeSlaEvent('sub-alert', 'failed'));
213+
214+
const alert = svc.getActiveAlerts().find((a) => a.ruleId === 'sla-breach:sub-alert');
215+
expect(alert).toBeDefined();
216+
expect(alert!.severity).toBe('critical'); // deviation 99% ≥ 5
217+
expect(alert!.correlationId).toBe(svc.getSlaBreaches('sub-alert')[0].id);
218+
});
219+
220+
it('does not open a breach while uptime stays at or above the target', () => {
221+
svc.setSlaTarget('sub-ok', { uptimeTarget: 99, measurementInterval: 86_400 });
222+
svc.recordTransaction(makeSlaEvent('sub-ok', 'success'));
223+
svc.recordTransaction(makeSlaEvent('sub-ok', 'success'));
224+
svc.recordTransaction(makeSlaEvent('sub-ok', 'failed')); // 66.67% → breach
225+
226+
expect(svc.getSlaBreaches('sub-ok')).toHaveLength(1);
227+
228+
// A second failure keeps the same single open breach (no duplicates).
229+
svc.recordTransaction(makeSlaEvent('sub-ok', 'failed'));
230+
expect(svc.getSlaBreaches('sub-ok')).toHaveLength(1);
231+
expect(svc.getSlaBreaches('sub-ok')[0].resolvedAt).toBeNull();
232+
});
233+
234+
it('ignores pending transactions when computing uptime', () => {
235+
svc.setSlaTarget('sub-pending', { uptimeTarget: 99, measurementInterval: 86_400 });
236+
svc.recordTransaction(makeSlaEvent('sub-pending', 'pending'));
237+
svc.recordTransaction(makeSlaEvent('sub-pending', 'failed'));
238+
svc.recordTransaction(makeSlaEvent('sub-pending', 'success'));
239+
240+
const status = svc.getSlaStatus('sub-pending');
241+
expect(status!.observedTransactions).toBe(2);
242+
expect(status!.uptimePercentage).toBe(50);
243+
expect(svc.getSlaBreaches('sub-pending')).toHaveLength(1);
244+
});
245+
246+
it('ignores transactions outside the measurement window', () => {
247+
const interval = 3_600;
248+
svc.setSlaTarget('sub-window', { uptimeTarget: 99, measurementInterval: interval });
249+
const stale = Date.now() - (interval * 1000 + 5_000);
250+
svc.recordTransaction(makeSlaEvent('sub-window', 'failed', stale));
251+
252+
const status = svc.getSlaStatus('sub-window');
253+
expect(status!.observedTransactions).toBe(0);
254+
expect(status!.compliant).toBe(true);
255+
expect(svc.getSlaBreaches('sub-window')).toHaveLength(0);
256+
});
257+
258+
it('auto-resolves the breach and its alert when uptime recovers', () => {
259+
svc.setSlaTarget('sub-recover', { uptimeTarget: 99, measurementInterval: 86_400 });
260+
svc.recordTransaction(makeSlaEvent('sub-recover', 'failed'));
261+
svc.recordTransaction(makeSlaEvent('sub-recover', 'success')); // 50% → breach
262+
263+
expect(svc.getSlaBreaches('sub-recover')).toHaveLength(1);
264+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'sla-breach:sub-recover')).toBe(true);
265+
266+
// 99 successes + 1 failure = 99% uptime → back at target.
267+
for (let i = 0; i < 98; i++) {
268+
svc.recordTransaction(makeSlaEvent('sub-recover', 'success'));
269+
}
270+
271+
const status = svc.getSlaStatus('sub-recover');
272+
expect(status!.compliant).toBe(true);
273+
expect(svc.getSlaBreaches('sub-recover')[0].resolvedAt).not.toBeNull();
274+
expect(status!.activeBreachId).toBeNull();
275+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'sla-breach:sub-recover')).toBe(false);
276+
});
277+
278+
it('tracks a full breach lifecycle: detect → resolve → detect again', () => {
279+
svc.setSlaTarget('sub-lifecycle', { uptimeTarget: 99, measurementInterval: 86_400 });
280+
svc.recordTransaction(makeSlaEvent('sub-lifecycle', 'failed'));
281+
svc.recordTransaction(makeSlaEvent('sub-lifecycle', 'success')); // breach #1
282+
283+
for (let i = 0; i < 98; i++) {
284+
svc.recordTransaction(makeSlaEvent('sub-lifecycle', 'success'));
285+
} // 99% → resolved
286+
287+
svc.recordTransaction(makeSlaEvent('sub-lifecycle', 'failed')); // 98.02% → breach #2
288+
svc.recordTransaction(makeSlaEvent('sub-lifecycle', 'failed'));
289+
290+
const breaches = svc.getSlaBreaches('sub-lifecycle');
291+
expect(breaches).toHaveLength(2);
292+
// Newest breach first, and it is the open one.
293+
expect(breaches[0].resolvedAt).toBeNull();
294+
expect(breaches[1].resolvedAt).not.toBeNull();
295+
expect(svc.getSlaStatus('sub-lifecycle')!.breachCount).toBe(2);
296+
});
297+
298+
it('monitors multiple subscriptions independently', () => {
299+
svc.setSlaTarget('sub-a', { uptimeTarget: 99, measurementInterval: 86_400 });
300+
svc.setSlaTarget('sub-b', { uptimeTarget: 99, measurementInterval: 86_400 });
301+
302+
svc.recordTransaction(makeSlaEvent('sub-a', 'failed'));
303+
svc.recordTransaction(makeSlaEvent('sub-a', 'success'));
304+
svc.recordTransaction(makeSlaEvent('sub-b', 'success'));
305+
306+
expect(svc.getSlaBreaches('sub-a')).toHaveLength(1);
307+
expect(svc.getSlaBreaches('sub-b')).toHaveLength(0);
308+
expect(svc.getSlaStatus('sub-b')!.compliant).toBe(true);
309+
});
310+
});
311+
312+
// ── SLA breach management ─────────────────────────────────────────────────
313+
314+
describe('SLA monitoring — breach management', () => {
315+
it('manually resolves a breach and its alert', () => {
316+
svc.setSlaTarget('sub-manual', { uptimeTarget: 99, measurementInterval: 86_400 });
317+
svc.recordTransaction(makeSlaEvent('sub-manual', 'failed'));
318+
const breach = svc.getSlaBreaches('sub-manual')[0];
319+
320+
svc.resolveSlaBreach(breach.id);
321+
expect(svc.getSlaBreaches('sub-manual')[0].resolvedAt).not.toBeNull();
322+
expect(svc.getActiveAlerts().some((a) => a.ruleId === 'sla-breach:sub-manual')).toBe(false);
323+
expect(svc.getSlaStatus('sub-manual')!.activeBreachId).toBeNull();
324+
});
325+
326+
it('acknowledges a breach', () => {
327+
svc.setSlaTarget('sub-ack', { uptimeTarget: 99, measurementInterval: 86_400 });
328+
svc.recordTransaction(makeSlaEvent('sub-ack', 'failed'));
329+
const breach = svc.getSlaBreaches('sub-ack')[0];
330+
331+
svc.acknowledgeSlaBreach(breach.id);
332+
expect(svc.getSlaBreaches('sub-ack')[0].acknowledged).toBe(true);
333+
});
334+
335+
it('is a no-op for unknown breach ids', () => {
336+
expect(() => svc.resolveSlaBreach('nope')).not.toThrow();
337+
expect(() => svc.acknowledgeSlaBreach('nope')).not.toThrow();
338+
});
339+
});
340+
341+
// ── SLA credit calculation ────────────────────────────────────────────────
342+
343+
describe('calculateSlaCreditAmount', () => {
344+
const target = { uptimeTarget: 99, measurementInterval: 86_400, creditCap: 0 };
345+
346+
it('returns 0 when uptime is at or above target', () => {
347+
expect(calculateSlaCreditAmount(target, 99)).toBe(0);
348+
expect(calculateSlaCreditAmount(target, 99.5)).toBe(0);
349+
});
350+
351+
it('returns at least 1 for any breach', () => {
352+
expect(calculateSlaCreditAmount(target, 98.9999)).toBeGreaterThanOrEqual(1);
353+
});
354+
355+
it('scales credit with the size of the deficit', () => {
356+
const small = calculateSlaCreditAmount(target, 98);
357+
const large = calculateSlaCreditAmount(target, 50);
358+
expect(large).toBeGreaterThan(small);
359+
});
360+
361+
it('respects the credit cap when set', () => {
362+
const capped = calculateSlaCreditAmount({ ...target, creditCap: 250 }, 50);
363+
expect(capped).toBe(250);
364+
});
365+
366+
it('is unaffected by an explicit zero cap', () => {
367+
const uncapped = calculateSlaCreditAmount({ ...target, creditCap: 0 }, 50);
368+
expect(uncapped).toBe(calculateSlaCreditAmount(target, 50));
369+
});
370+
});
371+
372+
// ── SLA summary & dashboard integration ───────────────────────────────────
373+
374+
describe('SLA monitoring — summary and dashboard', () => {
375+
it('exposes SLA data through getDashboard', () => {
376+
svc.setSlaTarget('sub-dash', { uptimeTarget: 99, measurementInterval: 86_400 });
377+
svc.recordTransaction(makeSlaEvent('sub-dash', 'failed'));
378+
svc.recordTransaction(makeSlaEvent('sub-dash', 'success'));
379+
380+
const dash = svc.getDashboard();
381+
expect(dash.slaStatuses).toHaveLength(1);
382+
expect(dash.slaStatuses[0].subscriptionId).toBe('sub-dash');
383+
expect(dash.slaBreaches).toHaveLength(1);
384+
expect(dash.slaSummary.totalMonitored).toBe(1);
385+
expect(dash.slaSummary.breached).toBe(1);
386+
expect(dash.slaSummary.openBreaches).toBe(1);
387+
expect(dash.slaSummary.totalCreditsIssued).toBeGreaterThan(0);
388+
});
389+
390+
it('aggregates summary across healthy and breached subscriptions', () => {
391+
svc.setSlaTarget('sub-healthy', { uptimeTarget: 99, measurementInterval: 86_400 });
392+
svc.setSlaTarget('sub-broken', { uptimeTarget: 99, measurementInterval: 86_400 });
393+
svc.recordTransaction(makeSlaEvent('sub-healthy', 'success'));
394+
svc.recordTransaction(makeSlaEvent('sub-broken', 'failed'));
395+
396+
const summary = svc.getSlaSummary();
397+
expect(summary.totalMonitored).toBe(2);
398+
expect(summary.compliant).toBe(1);
399+
expect(summary.breached).toBe(1);
400+
expect(summary.openBreaches).toBe(1);
401+
});
402+
403+
it('returns an empty summary when nothing is monitored', () => {
404+
expect(svc.getSlaSummary()).toEqual({
405+
totalMonitored: 0,
406+
compliant: 0,
407+
breached: 0,
408+
openBreaches: 0,
409+
totalCreditsIssued: 0,
410+
});
411+
});
412+
});
115413
});

0 commit comments

Comments
 (0)