Skip to content

Commit 8e84c01

Browse files
committed
feat: alerting threshold rules, SSE stream, dashboard badge (#250) (#255)
Implements issue #250 (part of Token Insights #247): - Alert types, event bus, DB migrations (SQLite + PG) - Config: 7 new settings with env overrides, clamping, webhook URL validation - AlertService: per-request threshold evaluation, anomaly detection, cooldown deduplication, webhook delivery - HTTP endpoints: history, config GET/POST, SSE stream, acknowledge, acknowledge-all - Dashboard: AlertsView, useAlertStream SSE hook, unacknowledged count nav badge
2 parents 4f42185 + fa576f6 commit 8e84c01

23 files changed

Lines changed: 1744 additions & 2 deletions

File tree

apps/server/src/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
DatabaseFactory,
2222
initPayloadEncryption,
2323
} from "@better-ccflare/database";
24-
import { APIRouter, AuthService } from "@better-ccflare/http-api";
24+
import { AlertService, APIRouter, AuthService } from "@better-ccflare/http-api";
2525
import {
2626
LeastUsedStrategy,
2727
SessionStrategy,
@@ -666,6 +666,10 @@ export default async function startServer(options?: {
666666
container.registerInstance(SERVICE_KEYS.PricingLogger, pricingLogger);
667667
setPricingLogger(pricingLogger);
668668

669+
const alertService = new AlertService(db, config);
670+
alertService.start();
671+
registerDisposable({ dispose: () => alertService.stop() });
672+
669673
// Strategy is constructed below after RuntimeConfig is built. The router
670674
// accepts a getter so it can read the live (post-hot-reload) instance.
671675
let currentStrategy: LoadBalancingStrategy | null = null;
@@ -674,6 +678,7 @@ export default async function startServer(options?: {
674678
db,
675679
config,
676680
dbOps,
681+
alertService,
677682
runtime: {
678683
port,
679684
tlsEnabled,
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { Config } from "./index";
6+
7+
const ENV_KEYS = [
8+
"ALERT_DAILY_SPEND_USD",
9+
"ALERT_TOKENS_PER_HOUR",
10+
"ALERT_REQUEST_TOKENS",
11+
"ALERT_ANOMALY_ENABLED",
12+
"ALERT_ANOMALY_INTERVAL_MINUTES",
13+
"ALERT_COOLDOWN_MINUTES",
14+
"ALERT_WEBHOOK_URL",
15+
] as const;
16+
17+
const ORIGINAL_ENV: Record<string, string | undefined> = {};
18+
for (const key of ENV_KEYS) {
19+
ORIGINAL_ENV[key] = process.env[key];
20+
}
21+
22+
function makeConfig(): { config: Config; cleanup: () => void } {
23+
const dir = mkdtempSync(join(tmpdir(), "better-ccflare-config-"));
24+
return {
25+
config: new Config(join(dir, "config.json")),
26+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
27+
};
28+
}
29+
30+
describe("alert config settings", () => {
31+
afterEach(() => {
32+
for (const key of ENV_KEYS) {
33+
const original = ORIGINAL_ENV[key];
34+
if (original === undefined) {
35+
delete process.env[key];
36+
} else {
37+
process.env[key] = original;
38+
}
39+
}
40+
});
41+
42+
it("returns defaults when nothing is configured", () => {
43+
for (const key of ENV_KEYS) {
44+
delete process.env[key];
45+
}
46+
const { config, cleanup } = makeConfig();
47+
48+
try {
49+
expect(config.getAlertDailySpendUsd()).toBe(0);
50+
expect(config.getAlertTokensPerHour()).toBe(0);
51+
expect(config.getAlertRequestTokens()).toBe(0);
52+
expect(config.getAlertAnomalyEnabled()).toBe(false);
53+
expect(config.getAlertAnomalyIntervalMinutes()).toBe(15);
54+
expect(config.getAlertCooldownMinutes()).toBe(60);
55+
expect(config.getAlertWebhookUrl()).toBe("");
56+
} finally {
57+
cleanup();
58+
}
59+
});
60+
61+
it("honors environment variable overrides", () => {
62+
process.env.ALERT_DAILY_SPEND_USD = "25.5";
63+
process.env.ALERT_TOKENS_PER_HOUR = "500000";
64+
process.env.ALERT_REQUEST_TOKENS = "200000";
65+
process.env.ALERT_ANOMALY_ENABLED = "true";
66+
process.env.ALERT_ANOMALY_INTERVAL_MINUTES = "30";
67+
process.env.ALERT_COOLDOWN_MINUTES = "120";
68+
process.env.ALERT_WEBHOOK_URL = "https://example.com/hook";
69+
const { config, cleanup } = makeConfig();
70+
71+
try {
72+
expect(config.getAlertDailySpendUsd()).toBe(25.5);
73+
expect(config.getAlertTokensPerHour()).toBe(500000);
74+
expect(config.getAlertRequestTokens()).toBe(200000);
75+
expect(config.getAlertAnomalyEnabled()).toBe(true);
76+
expect(config.getAlertAnomalyIntervalMinutes()).toBe(30);
77+
expect(config.getAlertCooldownMinutes()).toBe(120);
78+
expect(config.getAlertWebhookUrl()).toBe("https://example.com/hook");
79+
} finally {
80+
cleanup();
81+
}
82+
});
83+
84+
it("treats non-true anomaly env values as disabled", () => {
85+
process.env.ALERT_ANOMALY_ENABLED = "disabled";
86+
const { config, cleanup } = makeConfig();
87+
88+
try {
89+
expect(config.getAlertAnomalyEnabled()).toBe(false);
90+
} finally {
91+
cleanup();
92+
}
93+
});
94+
95+
it("clamps out-of-range environment values", () => {
96+
process.env.ALERT_DAILY_SPEND_USD = "-5";
97+
process.env.ALERT_TOKENS_PER_HOUR = "9999999999";
98+
process.env.ALERT_REQUEST_TOKENS = "-100";
99+
process.env.ALERT_ANOMALY_INTERVAL_MINUTES = "2";
100+
process.env.ALERT_COOLDOWN_MINUTES = "0";
101+
const { config, cleanup } = makeConfig();
102+
103+
try {
104+
expect(config.getAlertDailySpendUsd()).toBe(0);
105+
expect(config.getAlertTokensPerHour()).toBe(1_000_000_000);
106+
expect(config.getAlertRequestTokens()).toBe(0);
107+
expect(config.getAlertAnomalyIntervalMinutes()).toBe(5);
108+
expect(config.getAlertCooldownMinutes()).toBe(1);
109+
} finally {
110+
cleanup();
111+
}
112+
});
113+
114+
it("clamps out-of-range config-file values via setters", () => {
115+
for (const key of ENV_KEYS) {
116+
delete process.env[key];
117+
}
118+
const { config, cleanup } = makeConfig();
119+
120+
try {
121+
config.setAlertDailySpendUsd(2_000_000);
122+
expect(config.getAlertDailySpendUsd()).toBe(1_000_000);
123+
124+
config.setAlertAnomalyIntervalMinutes(3);
125+
expect(config.getAlertAnomalyIntervalMinutes()).toBe(5);
126+
127+
config.setAlertAnomalyIntervalMinutes(99999);
128+
expect(config.getAlertAnomalyIntervalMinutes()).toBe(1440);
129+
130+
config.setAlertCooldownMinutes(0);
131+
expect(config.getAlertCooldownMinutes()).toBe(1);
132+
} finally {
133+
cleanup();
134+
}
135+
});
136+
137+
it("persists setter values readable by getters", () => {
138+
for (const key of ENV_KEYS) {
139+
delete process.env[key];
140+
}
141+
const { config, cleanup } = makeConfig();
142+
143+
try {
144+
config.setAlertDailySpendUsd(50);
145+
config.setAlertTokensPerHour(1_000_000);
146+
config.setAlertRequestTokens(300_000);
147+
config.setAlertAnomalyEnabled(true);
148+
config.setAlertCooldownMinutes(45);
149+
config.setAlertWebhookUrl("https://hooks.example.com/alert");
150+
151+
expect(config.getAlertDailySpendUsd()).toBe(50);
152+
expect(config.getAlertTokensPerHour()).toBe(1_000_000);
153+
expect(config.getAlertRequestTokens()).toBe(300_000);
154+
expect(config.getAlertAnomalyEnabled()).toBe(true);
155+
expect(config.getAlertCooldownMinutes()).toBe(45);
156+
expect(config.getAlertWebhookUrl()).toBe(
157+
"https://hooks.example.com/alert",
158+
);
159+
} finally {
160+
cleanup();
161+
}
162+
});
163+
164+
it("accepts an empty webhook URL (disabled) and rejects invalid ones", () => {
165+
for (const key of ENV_KEYS) {
166+
delete process.env[key];
167+
}
168+
const { config, cleanup } = makeConfig();
169+
170+
try {
171+
expect(() => config.setAlertWebhookUrl("")).not.toThrow();
172+
expect(config.getAlertWebhookUrl()).toBe("");
173+
174+
expect(() => config.setAlertWebhookUrl("not-a-url")).toThrow();
175+
expect(() => config.setAlertWebhookUrl("ftp://example.com")).toThrow();
176+
expect(() =>
177+
config.setAlertWebhookUrl("http://example.com/hook"),
178+
).not.toThrow();
179+
} finally {
180+
cleanup();
181+
}
182+
});
183+
184+
it("includes alert settings in getAllSettings()", () => {
185+
for (const key of ENV_KEYS) {
186+
delete process.env[key];
187+
}
188+
const { config, cleanup } = makeConfig();
189+
190+
try {
191+
const settings = config.getAllSettings();
192+
expect(settings.alert_daily_spend_usd).toBe(0);
193+
expect(settings.alert_tokens_per_hour).toBe(0);
194+
expect(settings.alert_request_tokens).toBe(0);
195+
expect(settings.alert_anomaly_enabled).toBe(false);
196+
expect(settings.alert_anomaly_interval_minutes).toBe(15);
197+
expect(settings.alert_cooldown_minutes).toBe(60);
198+
expect(settings.alert_webhook_url).toBe("");
199+
} finally {
200+
cleanup();
201+
}
202+
});
203+
});

packages/config/src/index.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export interface ConfigData {
6262
usage_throttling_five_hour_enabled?: boolean;
6363
usage_throttling_weekly_enabled?: boolean;
6464
health_detail_enabled?: boolean;
65+
alert_daily_spend_usd?: number;
66+
alert_tokens_per_hour?: number;
67+
alert_request_tokens?: number;
68+
alert_anomaly_enabled?: boolean;
69+
alert_anomaly_interval_minutes?: number;
70+
alert_cooldown_minutes?: number;
71+
alert_webhook_url?: string;
6572
// Database configuration
6673
db_wal_mode?: boolean;
6774
db_busy_timeout_ms?: number;
@@ -416,6 +423,128 @@ export class Config extends EventEmitter {
416423
return false;
417424
}
418425

426+
getAlertDailySpendUsd(): number {
427+
const fromEnv = process.env.ALERT_DAILY_SPEND_USD;
428+
if (fromEnv) {
429+
const n = Number.parseFloat(fromEnv);
430+
if (!Number.isNaN(n)) return this.clamp(n, 0, 1_000_000);
431+
}
432+
const fromFile = this.data.alert_daily_spend_usd;
433+
if (typeof fromFile === "number") return this.clamp(fromFile, 0, 1_000_000);
434+
return 0;
435+
}
436+
437+
setAlertDailySpendUsd(value: number): void {
438+
this.set("alert_daily_spend_usd", this.clamp(value, 0, 1_000_000));
439+
}
440+
441+
getAlertTokensPerHour(): number {
442+
const fromEnv = process.env.ALERT_TOKENS_PER_HOUR;
443+
if (fromEnv) {
444+
const n = Number.parseInt(fromEnv, 10);
445+
if (!Number.isNaN(n)) return this.clamp(n, 0, 1_000_000_000);
446+
}
447+
const fromFile = this.data.alert_tokens_per_hour;
448+
if (typeof fromFile === "number") {
449+
return this.clamp(fromFile, 0, 1_000_000_000);
450+
}
451+
return 0;
452+
}
453+
454+
setAlertTokensPerHour(value: number): void {
455+
this.set("alert_tokens_per_hour", this.clamp(value, 0, 1_000_000_000));
456+
}
457+
458+
getAlertRequestTokens(): number {
459+
const fromEnv = process.env.ALERT_REQUEST_TOKENS;
460+
if (fromEnv) {
461+
const n = Number.parseInt(fromEnv, 10);
462+
if (!Number.isNaN(n)) return this.clamp(n, 0, 1_000_000_000);
463+
}
464+
const fromFile = this.data.alert_request_tokens;
465+
if (typeof fromFile === "number") {
466+
return this.clamp(fromFile, 0, 1_000_000_000);
467+
}
468+
return 0;
469+
}
470+
471+
setAlertRequestTokens(value: number): void {
472+
this.set("alert_request_tokens", this.clamp(value, 0, 1_000_000_000));
473+
}
474+
475+
getAlertAnomalyEnabled(): boolean {
476+
const fromEnv = parseEnabledEnvFlag(process.env.ALERT_ANOMALY_ENABLED);
477+
if (fromEnv !== undefined) {
478+
return fromEnv;
479+
}
480+
const fromFile = this.data.alert_anomaly_enabled;
481+
if (typeof fromFile === "boolean") return fromFile;
482+
return false;
483+
}
484+
485+
setAlertAnomalyEnabled(value: boolean): void {
486+
this.set("alert_anomaly_enabled", value);
487+
}
488+
489+
getAlertAnomalyIntervalMinutes(): number {
490+
const fromEnv = process.env.ALERT_ANOMALY_INTERVAL_MINUTES;
491+
if (fromEnv) {
492+
const n = Number.parseInt(fromEnv, 10);
493+
if (!Number.isNaN(n)) return this.clamp(n, 5, 1440);
494+
}
495+
const fromFile = this.data.alert_anomaly_interval_minutes;
496+
if (typeof fromFile === "number") return this.clamp(fromFile, 5, 1440);
497+
return 15;
498+
}
499+
500+
setAlertAnomalyIntervalMinutes(value: number): void {
501+
this.set("alert_anomaly_interval_minutes", this.clamp(value, 5, 1440));
502+
}
503+
504+
getAlertCooldownMinutes(): number {
505+
const fromEnv = process.env.ALERT_COOLDOWN_MINUTES;
506+
if (fromEnv) {
507+
const n = Number.parseInt(fromEnv, 10);
508+
if (!Number.isNaN(n)) return this.clamp(n, 1, 1440);
509+
}
510+
const fromFile = this.data.alert_cooldown_minutes;
511+
if (typeof fromFile === "number") return this.clamp(fromFile, 1, 1440);
512+
return 60;
513+
}
514+
515+
setAlertCooldownMinutes(value: number): void {
516+
this.set("alert_cooldown_minutes", this.clamp(value, 1, 1440));
517+
}
518+
519+
getAlertWebhookUrl(): string {
520+
const fromEnv = process.env.ALERT_WEBHOOK_URL;
521+
if (fromEnv !== undefined) return fromEnv;
522+
const fromFile = this.data.alert_webhook_url;
523+
if (typeof fromFile === "string") return fromFile;
524+
return "";
525+
}
526+
527+
setAlertWebhookUrl(value: string): void {
528+
if (value !== "") {
529+
let parsed: URL;
530+
try {
531+
parsed = new URL(value);
532+
} catch (_error) {
533+
throw new ValidationError(
534+
"Invalid alert webhook URL",
535+
"alert_webhook_url",
536+
);
537+
}
538+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
539+
throw new ValidationError(
540+
"Invalid alert webhook URL",
541+
"alert_webhook_url",
542+
);
543+
}
544+
}
545+
this.set("alert_webhook_url", value);
546+
}
547+
419548
getAllSettings(): Record<string, string | number | boolean | undefined> {
420549
// Include current strategy (which might come from env)
421550
return {
@@ -432,6 +561,13 @@ export class Config extends EventEmitter {
432561
this.getUsageThrottlingFiveHourEnabled(),
433562
usage_throttling_weekly_enabled: this.getUsageThrottlingWeeklyEnabled(),
434563
health_detail_enabled: this.getHealthDetailEnabled(),
564+
alert_daily_spend_usd: this.getAlertDailySpendUsd(),
565+
alert_tokens_per_hour: this.getAlertTokensPerHour(),
566+
alert_request_tokens: this.getAlertRequestTokens(),
567+
alert_anomaly_enabled: this.getAlertAnomalyEnabled(),
568+
alert_anomaly_interval_minutes: this.getAlertAnomalyIntervalMinutes(),
569+
alert_cooldown_minutes: this.getAlertCooldownMinutes(),
570+
alert_webhook_url: this.getAlertWebhookUrl(),
435571
};
436572
}
437573

0 commit comments

Comments
 (0)