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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user