feat(sase): deploy regression Telegram alert

Closes the alert side of the deploy-regression view added in Faz 2b.
The dashboard table already flagged regressed deploys; this commit
pushes a Telegram when one happens, so MTTD doesn't depend on the
founder checking the dashboard.

Detection (panel)
- detectVinRegressions() pulls the last 20 Coolify deploys for the
  Sase.tr app, filters to those whose post-window has elapsed (≥30min
  since finishedAt) and isn't too old (≤180min since finishedAt), and
  reuses analyzeDeployRegressions to compute the 30min before/after
  success-rate slices. A row is flagged when:
    - both before and after have ≥5 samples, and
    - success rate dropped ≥10pp (severity 'high'; ≥15pp → 'critical').
- Returns a RegressionHit per flagged deploy with a 24h dedupe TTL
  keyed on deploymentUuid so each deploy alerts exactly once ever
  (regardless of how often the 5-min cron checks).

Endpoint
- GET /api/internal/vin-anomaly-check now returns
  { ok, current, baseline, anomalies, regressions }.

Worker
- sendTelegram() accepts an optional dedupeTtlSeconds override so
  per-call long-TTL dedupes (like deploy alerts) don't have to go
  through the global env default.
- New alertVinRegression() formats severity icon + before/after %
  + deploy commit/timestamp + dashboard link.
- runVinAnomalyDetect now also walks the regressions array and
  fires Telegram for each. Returns { anomalies, regressions,
  alertsFired, alertsDeduped }; pipeline log prints when either
  count is non-zero.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 13:33:07 +03:00
parent b5ba8919b8
commit a10996f6c5
5 changed files with 170 additions and 13 deletions

View File

@@ -1,4 +1,8 @@
import { alertVinAnomaly, isTelegramConfigured } from "../lib/telegram";
import {
alertVinAnomaly,
alertVinRegression,
isTelegramConfigured,
} from "../lib/telegram";
const PANEL_BASE =
process.env.PANEL_INTERNAL_URL ??
@@ -19,27 +23,60 @@ type AnomalyHit = {
dedupe_key: string;
};
type RegressionHit = {
type: "deploy_regression";
severity: "high" | "critical";
message: string;
deploymentUuid: string;
commit: string | null;
deployStartedAt: string;
before: { successRate: number; total: number };
after: { successRate: number; total: number };
deltaPp: number;
detected_at: string;
dedupe_key: string;
dedupe_ttl_seconds: number;
};
type CheckResponse = {
ok: boolean;
error?: string;
current?: { total: number; successRate: number };
baseline?: { total: number; successRate: number };
anomalies?: AnomalyHit[];
regressions?: RegressionHit[];
};
export async function runVinAnomalyDetect(): Promise<{
ok: boolean;
checked: boolean;
anomalies: number;
regressions: number;
alertsFired: number;
alertsDeduped: number;
reason?: string;
}> {
if (!WORKER_TOKEN) {
return { ok: false, checked: false, anomalies: 0, alertsFired: 0, alertsDeduped: 0, reason: "INTERNAL_WORKER_TOKEN not set" };
return {
ok: false,
checked: false,
anomalies: 0,
regressions: 0,
alertsFired: 0,
alertsDeduped: 0,
reason: "INTERNAL_WORKER_TOKEN not set",
};
}
if (!isTelegramConfigured()) {
return { ok: false, checked: false, anomalies: 0, alertsFired: 0, alertsDeduped: 0, reason: "telegram not configured" };
return {
ok: false,
checked: false,
anomalies: 0,
regressions: 0,
alertsFired: 0,
alertsDeduped: 0,
reason: "telegram not configured",
};
}
let res: Response;
@@ -55,6 +92,7 @@ export async function runVinAnomalyDetect(): Promise<{
ok: false,
checked: false,
anomalies: 0,
regressions: 0,
alertsFired: 0,
alertsDeduped: 0,
reason: `fetch failed: ${(e as Error).message}`,
@@ -65,17 +103,19 @@ export async function runVinAnomalyDetect(): Promise<{
ok: false,
checked: false,
anomalies: 0,
regressions: 0,
alertsFired: 0,
alertsDeduped: 0,
reason: `panel returned ${res.status}`,
};
}
const data = (await res.json()) as CheckResponse;
if (!data.ok || !data.anomalies) {
if (!data.ok) {
return {
ok: !!data.ok,
ok: false,
checked: true,
anomalies: 0,
regressions: 0,
alertsFired: 0,
alertsDeduped: 0,
reason: data.error,
@@ -83,17 +123,25 @@ export async function runVinAnomalyDetect(): Promise<{
}
const panelPublic = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
const anomalies = data.anomalies ?? [];
const regressions = data.regressions ?? [];
let fired = 0;
let deduped = 0;
for (const a of data.anomalies) {
for (const a of anomalies) {
const r = await alertVinAnomaly({ panelUrl: panelPublic, ...a });
if (r.ok && r.deduped) deduped++;
else if (r.ok) fired++;
}
for (const r of regressions) {
const out = await alertVinRegression({ panelUrl: panelPublic, ...r });
if (out.ok && out.deduped) deduped++;
else if (out.ok) fired++;
}
return {
ok: true,
checked: true,
anomalies: data.anomalies.length,
anomalies: anomalies.length,
regressions: regressions.length,
alertsFired: fired,
alertsDeduped: deduped,
};

View File

@@ -16,6 +16,7 @@ export type TelegramSendResult = { ok: boolean; deduped?: boolean; error?: strin
export async function sendTelegram(opts: {
text: string;
dedupeKey?: string; // optional — skip if same key fired in last TTL window
dedupeTtlSeconds?: number; // override the env default for this send
silent?: boolean;
parseMode?: "HTML" | "Markdown";
}): Promise<TelegramSendResult> {
@@ -24,7 +25,8 @@ export async function sendTelegram(opts: {
// Dedupe check
if (opts.dedupeKey) {
const key = `tg:dedupe:${opts.dedupeKey}`;
const set = await redis.set(key, "1", "EX", DEDUPE_TTL_SECONDS, "NX").catch(() => null);
const ttl = opts.dedupeTtlSeconds ?? DEDUPE_TTL_SECONDS;
const set = await redis.set(key, "1", "EX", ttl, "NX").catch(() => null);
if (set === null) return { ok: true, deduped: true };
}
@@ -145,6 +147,40 @@ export function alertVinAnomaly(
return sendTelegram({ text, dedupeKey: opts.dedupe_key });
}
export type VinRegressionAlert = {
type: "deploy_regression";
severity: "high" | "critical";
message: string;
deploymentUuid: string;
commit: string | null;
deployStartedAt: string;
before: { successRate: number; total: number };
after: { successRate: number; total: number };
deltaPp: number;
detected_at: string;
dedupe_key: string;
dedupe_ttl_seconds: number;
};
export function alertVinRegression(
opts: { panelUrl: string } & VinRegressionAlert,
): Promise<TelegramSendResult> {
const icon = opts.severity === "critical" ? "🔴" : "🟠";
const commit = opts.commit?.slice(0, 8) ?? "?";
const text = [
`${icon} <b>VIN regression after deploy</b>`,
escapeHtml(opts.message),
`Deploy: <code>${commit}</code> · ${opts.deployStartedAt.slice(0, 16).replace("T", " ")}`,
``,
`<a href="${opts.panelUrl}/projects/sase/vin-decode">VIN Decode dashboard</a>`,
].join("\n");
return sendTelegram({
text,
dedupeKey: opts.dedupe_key,
dedupeTtlSeconds: opts.dedupe_ttl_seconds,
});
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@@ -80,9 +80,9 @@ async function runJob(job: Job) {
}
case "vin-anomaly-detect": {
const res = await runVinAnomalyDetect();
if (res.anomalies > 0 || !res.ok) {
if (res.anomalies > 0 || res.regressions > 0 || !res.ok) {
console.log(
`[pipeline] vin-anomaly checked=${res.checked} anomalies=${res.anomalies} fired=${res.alertsFired} deduped=${res.alertsDeduped}${res.reason ? ` reason=${res.reason}` : ""}`,
`[pipeline] vin-anomaly checked=${res.checked} anomalies=${res.anomalies} regressions=${res.regressions} fired=${res.alertsFired} deduped=${res.alertsDeduped}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;