Skip to content

Commit 568769f

Browse files
Merge pull request #6 from corespan/alert-message
error-alert message for the service
2 parents da6d4f8 + 764e6b2 commit 568769f

6 files changed

Lines changed: 228 additions & 4 deletions

File tree

‎.env.example‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,19 @@ PROXY_BASE_URL=https://your-domain.com:8443
8383
NGINX_CONF_DIR=/etc/nginx/jupyter-locations
8484
# inotifywait inside the nginx container auto-reloads on file changes — no reload command needed.
8585
NGINX_RELOAD_CMD=true
86+
87+
# Slack error alerts — posted to your #aistudio-alerts channel.
88+
# Create one at: https://api.slack.com/apps -> (your app) -> Incoming Webhooks
89+
# -> Add New Webhook to Workspace -> pick #aistudio-alerts -> copy the URL.
90+
# Leave blank to disable alerting entirely (every alert call becomes a no-op).
91+
SLACK_ALERT_WEBHOOK_URL=
92+
# "Where is this service running" for a SERVICE-level alert (an unhandled API
93+
# exception, or /health finding Postgres unreachable) — these alerts have no
94+
# workload, so this is reported instead. Leave blank to auto-detect via the
95+
# container/host's own hostname resolution, but note that inside an
96+
# unmodified Docker bridge network this resolves to the CONTAINER's internal
97+
# IP, not a reachable host IP — set this explicitly in any real deployment.
98+
SERVICE_HOST_IP=
99+
# Minimum seconds between two service-level alerts, so a prolonged Postgres
100+
# outage (polled by /health every few seconds) doesn't flood the channel.
101+
SLACK_SERVICE_ALERT_COOLDOWN_SECONDS=300

‎app/config.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ class Settings(BaseSettings):
7777
# Override via environment variable JUPYTER_ASSISTANT_URL.
7878
JUPYTER_ASSISTANT_URL: str = ""
7979

80+
# ── Slack Error Alerts ────────────────────────────────────────────────
81+
# Incoming Webhook URL for the #aistudio-alerts channel (Slack App ->
82+
# "Incoming Webhooks" -> Add New Webhook to Workspace). Leave blank to
83+
# disable alerting entirely (every call becomes a no-op) — useful for
84+
# local dev where you don't want your own errors paging the channel.
85+
SLACK_ALERT_WEBHOOK_URL: str = ""
86+
# Explicit override for "where is this service running" in a service-level
87+
# alert (main.py's generic exception handler / a degraded /health check).
88+
# Leave blank to auto-detect via socket.gethostbyname(gethostname()) — note
89+
# that inside an unmodified Docker bridge network this usually resolves to
90+
# the CONTAINER's internal IP, not a reachable host IP, so set this
91+
# explicitly in production (e.g. to the host's LAN IP or public hostname).
92+
SERVICE_HOST_IP: str = ""
93+
# Minimum seconds between two service-level alerts. Protects the channel
94+
# from being flooded if something (e.g. Postgres) is down for a while and
95+
# /health is being polled every few seconds by an external monitor.
96+
# Per-process only — not shared across multiple uvicorn workers/replicas.
97+
SLACK_SERVICE_ALERT_COOLDOWN_SECONDS: int = 300
98+
8099
class Config:
81100
env_file = ".env"
82101
env_file_encoding = "utf-8"

‎app/main.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from fastapi.exceptions import RequestValidationError
55

66
from app.routers import system, ingest, benchmarks, results, jupyter, gpu_specs
7+
from app.services.slack_notifier import notify_service_error
78

89
# The app instance
910
app = FastAPI(
@@ -52,6 +53,12 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
5253

5354
@app.exception_handler(Exception)
5455
async def generic_exception_handler(request: Request, exc: Exception):
56+
# Service-level error -- no workload is necessarily involved, so the
57+
# alert reports only when/where, not what (see slack_notifier.py). This
58+
# is also the ONLY place an unhandled API exception got logged/surfaced
59+
# anywhere server-side before this change -- previously it only went
60+
# back to the client in the JSON response.
61+
await notify_service_error()
5562
return JSONResponse(
5663
status_code=500,
5764
content={

‎app/routers/system.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.catalog import _DEFAULT_CONFIG, _MODEL_CONFIGS, _MODEL_INFO
66
from app.database import get_db
77
from app.models.workload_type import WorkloadType
8+
from app.services.slack_notifier import notify_service_error
89

910
router = APIRouter(tags=["System"])
1011

@@ -21,7 +22,12 @@ async def health_check(db: AsyncSession = Depends(get_db)):
2122
db_status = "ok"
2223
except Exception as e:
2324
db_status = f"unreachable: {str(e)}"
24-
25+
# Service is down (or erroring while trying to come back up) --
26+
# alert with just when + where; notify_service_error() rate-limits
27+
# itself so a monitor polling /health every few seconds during a
28+
# prolonged outage doesn't flood the channel with one post each poll.
29+
await notify_service_error()
30+
2531
return {
2632
"status": "healthy" if db_status == "ok" else "degraded",
2733
"database": db_status,

‎app/services/slack_notifier.py‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""
2+
Slack error alerting -- best-effort, fire-and-forget notifications to the
3+
#aistudio-alerts channel.
4+
5+
Two alert shapes, matching exactly what the RCA agent needs to start digging
6+
in, and nothing more (no error text is sent -- the agent recovers that
7+
itself, from workload_events.message / workloads.error_message / task_logs,
8+
via the tools it already has):
9+
10+
1. Workload failure -> Workload ID, Timestamp, Node IP
11+
Fired from app.worker._fail_workload() -- the single choke point every
12+
Celery task (validate_node / install_dependencies / execute_benchmark /
13+
launch_jupyter) already routes through on any failure, so one hook here
14+
covers every "error in benchmarking / starting the benchmark / etc"
15+
scenario without instrumenting each task individually.
16+
17+
2. Service error -> Timestamp, Service IP
18+
Fired when the API process itself is in trouble: an unhandled exception
19+
(app.main's generic_exception_handler) or a degraded /health check
20+
(Postgres unreachable, i.e. "down, or erroring while trying to come back
21+
up"). No workload is involved here, so there's no workload ID to
22+
report -- just where the *service* is running.
23+
24+
Uses httpx (already a project dependency) against a Slack Incoming Webhook
25+
URL rather than the Slack SDK/bot token: an Incoming Webhook is a single
26+
POST of {"text": "..."} to a per-channel URL -- no bot token, no signing
27+
secret, no app-level OAuth needed on THIS side (that machinery only matters
28+
for the side that has to *read* Slack, which is the RCA agent, not here).
29+
30+
Every public function is wrapped so a Slack outage or missing/blank webhook
31+
URL can NEVER raise into the caller's real code path -- a Celery task
32+
failing to post an alert must still finish marking the workload FAILED, and
33+
a FastAPI request failing to post an alert must still return its error
34+
response to the client.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import logging
40+
import socket
41+
from datetime import datetime, timedelta, timezone
42+
43+
import httpx
44+
45+
from app.config import settings
46+
47+
logger = logging.getLogger(__name__)
48+
49+
_ISO_FMT = "%Y-%m-%dT%H:%M:%SZ"
50+
51+
52+
def _resolve_service_ip() -> str:
53+
"""settings.SERVICE_HOST_IP if set, else a best-effort auto-detect.
54+
55+
NOTE: inside an unmodified Docker bridge network, gethostbyname(hostname())
56+
resolves to the CONTAINER's internal IP, not a reachable host IP -- set
57+
SERVICE_HOST_IP explicitly in any deployment where that distinction matters
58+
(which is effectively every real deployment).
59+
"""
60+
if settings.SERVICE_HOST_IP:
61+
return settings.SERVICE_HOST_IP
62+
try:
63+
return socket.gethostbyname(socket.gethostname())
64+
except OSError:
65+
return "unknown"
66+
67+
68+
def _fmt_ts(ts: datetime) -> str:
69+
return ts.astimezone(timezone.utc).strftime(_ISO_FMT)
70+
71+
72+
def _workload_failure_text(workload_id: str, node_ip: str | None, occurred_at: datetime) -> str:
73+
return (
74+
"🚨 *Workload Failed*\n"
75+
f"Workload: `{workload_id}`\n"
76+
f"Time: `{_fmt_ts(occurred_at)}`\n"
77+
f"Node: `{node_ip or 'unknown'}`"
78+
)
79+
80+
81+
def _service_error_text(occurred_at: datetime, service_ip: str) -> str:
82+
return (
83+
"🔴 *aistudio-server error*\n"
84+
f"Time: `{_fmt_ts(occurred_at)}`\n"
85+
f"Service: `{service_ip}`"
86+
)
87+
88+
89+
def _send_sync(text: str) -> None:
90+
"""Post from a synchronous context (the Celery worker)."""
91+
if not settings.SLACK_ALERT_WEBHOOK_URL:
92+
return # alerting disabled -- no-op, not an error
93+
try:
94+
resp = httpx.post(settings.SLACK_ALERT_WEBHOOK_URL, json={"text": text}, timeout=5.0)
95+
resp.raise_for_status()
96+
except Exception as exc: # noqa: BLE001 -- alerting must never break the caller
97+
logger.warning("slack_notifier: failed to post alert: %s", exc)
98+
99+
100+
async def _send_async(text: str) -> None:
101+
"""Post from an async context (FastAPI request handlers)."""
102+
if not settings.SLACK_ALERT_WEBHOOK_URL:
103+
return
104+
try:
105+
async with httpx.AsyncClient(timeout=5.0) as client:
106+
resp = await client.post(settings.SLACK_ALERT_WEBHOOK_URL, json={"text": text})
107+
resp.raise_for_status()
108+
except Exception as exc: # noqa: BLE001
109+
logger.warning("slack_notifier: failed to post alert: %s", exc)
110+
111+
112+
def notify_workload_failure(
113+
workload_id: str,
114+
node_ip: str | None,
115+
occurred_at: datetime | None = None,
116+
) -> None:
117+
"""Fire a workload-failure alert. Call from a SYNCHRONOUS context (Celery tasks).
118+
119+
Args:
120+
workload_id: The human-readable workload id (e.g. 'wl-20260806-oom01').
121+
node_ip: The GPU node the workload was running on, if known.
122+
occurred_at: When the failure was detected. Defaults to now() -- pass
123+
an explicit value if the caller already captured a more precise
124+
moment (e.g. right when the exception was caught).
125+
"""
126+
occurred_at = occurred_at or datetime.now(timezone.utc)
127+
_send_sync(_workload_failure_text(workload_id, node_ip, occurred_at))
128+
129+
130+
# Service-level alert cooldown -- per-process only (see
131+
# SLACK_SERVICE_ALERT_COOLDOWN_SECONDS' docstring in config.py for why).
132+
_last_service_alert_at: datetime | None = None
133+
134+
135+
def _service_cooldown_active(now: datetime) -> bool:
136+
global _last_service_alert_at
137+
cooldown = timedelta(seconds=settings.SLACK_SERVICE_ALERT_COOLDOWN_SECONDS)
138+
if _last_service_alert_at is not None and (now - _last_service_alert_at) < cooldown:
139+
return True
140+
_last_service_alert_at = now
141+
return False
142+
143+
144+
async def notify_service_error(occurred_at: datetime | None = None) -> None:
145+
"""Fire a service-level alert. Call from an ASYNC context (FastAPI).
146+
147+
Rate-limited by SLACK_SERVICE_ALERT_COOLDOWN_SECONDS so a Postgres outage
148+
being polled by /health every few seconds doesn't flood the channel with
149+
one alert per poll.
150+
"""
151+
now = occurred_at or datetime.now(timezone.utc)
152+
if _service_cooldown_active(now):
153+
return
154+
await _send_async(_service_error_text(now, _resolve_service_ip()))

‎app/worker.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from app.services.manifest_builder import ManifestBuilder
2222
from app.services.nginx_proxy import write_jupyter_config, proxy_url as nginx_proxy_url, jupyter_base_path
2323
from app.services.ssh_executor import SSHExecutor
24+
from app.services.slack_notifier import notify_workload_failure
2425
from app.services.state_machine import transition_workload_state
2526

2627
logger = logging.getLogger(__name__)
@@ -89,30 +90,45 @@ def _fail_workload(workload_id, trigger, error):
8990
For benchmark workloads (not Jupyter) also writes a BenchmarkResult row with
9091
status='failed' so the run appears in the leaderboard API after a page refresh
9192
instead of disappearing when the in-memory stream store is cleared.
93+
94+
This is the single choke point every Celery task (validate_node /
95+
install_dependencies / execute_benchmark / launch_jupyter) routes through
96+
on any failure -- so it's also the one place we fire the Slack alert,
97+
rather than instrumenting each task's except block individually.
9298
"""
99+
occurred_at = datetime.now(timezone.utc)
100+
node_ip = None
93101
try:
94102
with SyncSessionLocal() as db:
95103
transition_workload_state(
96104
db, workload_id, WorkloadState.FAILED,
97105
trigger=trigger, message=error,
98106
)
107+
workload = db.query(Workload).filter(Workload.workload_id == workload_id).first()
108+
109+
# Node lookup -- always attempted (benchmark AND jupyter workloads)
110+
# so the Slack alert can report where the workload was running.
111+
# A workload can fail before any node was ever assigned (e.g. "No
112+
# nodes available"), so node_ip may legitimately stay None.
113+
if workload:
114+
node = db.query(Node).filter(Node.workload_id == workload.id).first()
115+
node_ip = node.machine_ip if node else None
116+
99117
# Write a minimal BenchmarkResult so GET /api/v1/benchmarks returns
100118
# the failed run. Skip Jupyter workloads — they have no benchmark metrics.
101-
workload = db.query(Workload).filter(Workload.workload_id == workload_id).first()
102119
if workload and (workload.workload_config or {}).get("workload_type") != "jupyter":
103120
existing = db.query(BenchmarkResult).filter(
104121
BenchmarkResult.run_id == workload_id
105122
).first()
106123
now = datetime.now(timezone.utc)
107124
if not existing:
108-
node = db.query(Node).filter(Node.workload_id == workload.id).first()
109125
db.add(BenchmarkResult(
110126
run_id=workload_id,
111127
sub_run_index=0,
112128
workload_type="llm",
113129
model_name=(workload.model_name or "").lower(),
114130
pipeline_version="vllm-openai:v0.14.1",
115-
node_ips=[node.machine_ip] if node else [],
131+
node_ips=[node_ip] if node_ip else [],
116132
gpu_type="",
117133
gpu_count=0,
118134
gpu_model="",
@@ -145,6 +161,12 @@ def _fail_workload(workload_id, trigger, error):
145161
workload_id, trigger,
146162
exc_info=True,
147163
)
164+
return # DB write itself failed -- state is unreliable, don't alert on it
165+
166+
# Fired outside the try/except above: the FAILED state is durably written
167+
# by this point, and notify_workload_failure() never raises (Slack being
168+
# down must not affect this function's real job of failing the workload).
169+
notify_workload_failure(workload_id, node_ip, occurred_at)
148170

149171

150172
def _fetch_workload_and_nodes(db, workload_id: str):

0 commit comments

Comments
 (0)