Skip to content

Commit 81869ca

Browse files
author
Harbor
committed
fix(slack): dedupe user overage alerts daily
1 parent b9b2582 commit 81869ca

3 files changed

Lines changed: 49 additions & 6 deletions

File tree

backend/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Common optional settings:
185185
- provider keys such as `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_VERSION`, `ODDISH_AZURE_OPENAI_DEPLOYMENTS`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `DAYTONA_API_KEY`
186186
- `ODDISH_OPENAI_PROVIDER=openai` plus `OPENAI_API_KEY` only when intentionally routing OpenAI-family jobs to public OpenAI
187187
- GitHub notifier settings such as `GITHUB_TOKEN` and `ODDISH_DASHBOARD_URL`
188-
- `SLACK_ALERT_BOT_TOKEN` (scopes `chat:write`, `im:write`, `users:read.email`) for deterministic cost alerts, which DM an experiment's owner: a milestone for each $1,000 spent in the past 24 hours, and any trial over $200 that finished in that window. The same token delivers the other DM-only alerts -- trial failed, QA failed, experiment failed -- and resolves in-channel mentions by account email. The email delivery channel has been removed entirely. `SLACK_EXPENSE_WEBHOOK_URL` carries what is left in-channel: unpriceable-model alerts from the past 24 hours; an escalation when a running or retrying trial's live cost rises above the configured floor, which `<@...>`-mentions its owner plus the always-ping list; and a `<!channel>` alert when a user's rolling seven-day spend, including live running-trial checkpoints, rises more than the configured dollar delta above their workspace's average spender. The two channel escalation thresholds and the ping list are set by an admin on the Costs tab of `/admin` and stored in `slack_alert_settings`; the constants in `slack_alert_settings.py` are the defaults they override, and no setting here is environment-configurable. Weekly user alerts re-arm after spend drops back below the threshold. The per-user DM cutoffs (the milestone and completed-trial floor) are separate deploy-time constants in `user_alert_prefs.py` that each person tunes in their own notification settings, not admin-editable here. Notifications are on by default for the production app; previews opt in with `ODDISH_ENABLE_SLACK_EXPENSE_NOTIFICATIONS=true` and can attach a preview-only notification secret via `ODDISH_SLACK_EXPENSE_SECRET_NAME` / `ODDISH_SLACK_EXPENSE_SECRET_ENVIRONMENT`.
188+
- `SLACK_ALERT_BOT_TOKEN` (scopes `chat:write`, `im:write`, `users:read.email`) for deterministic cost alerts, which DM an experiment's owner: a milestone for each $1,000 spent in the past 24 hours, and any trial over $200 that finished in that window. The same token delivers the other DM-only alerts -- trial failed, QA failed, experiment failed -- and resolves in-channel mentions by account email. The email delivery channel has been removed entirely. `SLACK_EXPENSE_WEBHOOK_URL` carries what is left in-channel: unpriceable-model alerts from the past 24 hours; an escalation when a running or retrying trial's live cost rises above the configured floor, which `<@...>`-mentions its owner plus the always-ping list; and a `<!channel>` alert when a user's rolling seven-day spend, including live running-trial checkpoints, rises more than the configured dollar delta above their workspace's average spender. The two channel escalation thresholds and the ping list are set by an admin on the Costs tab of `/admin` and stored in `slack_alert_settings`; the constants in `slack_alert_settings.py` are the defaults they override, and no setting here is environment-configurable. User daily-overage alerts fire at most once per UTC calendar day. The per-user DM cutoffs (the milestone and completed-trial floor) are separate deploy-time constants in `user_alert_prefs.py` that each person tunes in their own notification settings, not admin-editable here. Notifications are on by default for the production app; previews opt in with `ODDISH_ENABLE_SLACK_EXPENSE_NOTIFICATIONS=true` and can attach a preview-only notification secret via `ODDISH_SLACK_EXPENSE_SECRET_NAME` / `ODDISH_SLACK_EXPENSE_SECRET_ENVIRONMENT`.
189189
- `ODDISH_SLACK_UNFURL_*` for a lean, single-workspace Slack app that unfurls Oddish task, experiment, and public-share links. It requires `links:read` and `links:write`, a `link_shared` event subscription pointed at `/webhooks/slack/events`, a signing secret, bot token, and bound Oddish org. Optional team/channel allowlists add defense in depth. This is separate from the expense notifications above.
190190
- `ODDISH_CARL_*`, `ODDISH_API_KEY`, and `ODDISH_DATABASE_URL_RO` extend that same Slack app with read-only answers to permitted `app_mention` events. Carl keeps the existing `/webhooks/slack/events` URL and `link_shared` subscription; add `app_mentions:read` and subscribe the installed app to `app_mention`. The SQL DSN must use a dedicated non-superuser role restricted to the analytics table allow-list. See `slackbot/README.md`.
191191

backend/slack_notifications.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ def build_alerts(
256256
user_prefs: Mapping[str, UserAlertPrefs] | None = None,
257257
) -> list[SlackAlert]:
258258
prefs_by_email = user_prefs or {}
259+
alert_date = (recent_cutoff + timedelta(days=1)).astimezone(timezone.utc).date()
259260

260261
def prefs_for(email: str | None) -> UserAlertPrefs:
261262
return prefs_by_email.get(
@@ -417,7 +418,7 @@ def add_failure_dm(
417418
continue
418419
alerts.append(
419420
SlackAlert(
420-
key=f"user-daily-overage:{user.org_id}:{user.user_id}",
421+
key=f"user-daily-overage:{user.org_id}:{user.user_id}:{alert_date}",
421422
text=(
422423
"<!channel>\n"
423424
":moneybag: *User spend above their 7-day daily average*\n"

backend/tests/test_slack_notifications.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ def test_build_alerts_does_not_channel_escalate_a_finished_trial() -> None:
495495

496496

497497
def test_build_alerts_pings_channel_for_user_above_daily_overage_margin() -> None:
498+
now = datetime(2026, 8, 18, 12, tzinfo=timezone.utc)
498499
alerts = build_alerts(
499500
AlertCandidates(
500501
user_spend=[
@@ -509,11 +510,13 @@ def test_build_alerts_pings_channel_for_user_above_daily_overage_margin() -> Non
509510
]
510511
),
511512
settings=DEFAULT_ALERT_SETTINGS,
512-
recent_cutoff=datetime.now(timezone.utc) - timedelta(hours=24),
513+
recent_cutoff=now - timedelta(hours=24),
513514
dashboard_url="https://www.oddish.app",
514515
)
515516

516-
assert [alert.key for alert in alerts] == ["user-daily-overage:org-1:user-1"]
517+
assert [alert.key for alert in alerts] == [
518+
"user-daily-overage:org-1:user-1:2026-08-18"
519+
]
517520
assert alerts[0].text.splitlines() == [
518521
"<!channel>",
519522
":moneybag: *User spend above their 7-day daily average*",
@@ -530,6 +533,7 @@ def test_build_alerts_pings_channel_for_user_above_daily_overage_margin() -> Non
530533
def test_build_alerts_user_daily_overage_is_exclusive_and_configurable() -> None:
531534
# 24h spend $6,000 runs $5,000 above the $1,000 daily average.
532535
candidate = UserSpend("org-1", "user-1", "Pat", 6_000, 1_000, 0)
536+
now = datetime(2026, 8, 18, 12, tzinfo=timezone.utc)
533537

534538
def keys(delta: float) -> list[str]:
535539
alerts = build_alerts(
@@ -538,13 +542,51 @@ def keys(delta: float) -> list[str]:
538542
DEFAULT_ALERT_SETTINGS,
539543
user_daily_overage_delta_usd=delta,
540544
),
541-
recent_cutoff=datetime.now(timezone.utc) - timedelta(hours=24),
545+
recent_cutoff=now - timedelta(hours=24),
542546
dashboard_url="https://www.oddish.app",
543547
)
544548
return [alert.key for alert in alerts]
545549

546550
assert keys(5_000) == []
547-
assert keys(4_999) == ["user-daily-overage:org-1:user-1"]
551+
assert keys(4_999) == ["user-daily-overage:org-1:user-1:2026-08-18"]
552+
553+
554+
@pytest.mark.asyncio
555+
async def test_user_daily_overage_alerts_once_per_utc_day(
556+
monkeypatch: pytest.MonkeyPatch,
557+
) -> None:
558+
rows: dict[str, dict] = {}
559+
posted: list[str] = []
560+
candidates = AlertCandidates(
561+
user_spend=[UserSpend("org-1", "user-1", "Pat", 6_000, 1_000, 0)]
562+
)
563+
564+
async def post(*_args) -> None:
565+
posted.append("")
566+
567+
def alerts(now: datetime) -> list[SlackAlert]:
568+
return build_alerts(
569+
candidates,
570+
settings=DEFAULT_ALERT_SETTINGS,
571+
recent_cutoff=now - timedelta(hours=24),
572+
dashboard_url="https://www.oddish.app",
573+
)
574+
575+
_outbox_stubs(monkeypatch, rows)
576+
monkeypatch.setattr(notifications, "_post", post)
577+
578+
for now in (
579+
datetime(2026, 8, 18, 1, tzinfo=timezone.utc),
580+
datetime(2026, 8, 18, 23, tzinfo=timezone.utc),
581+
datetime(2026, 8, 19, 1, tzinfo=timezone.utc),
582+
):
583+
await _record_and_deliver(alerts(now), webhook_url="https://hooks.slack.test")
584+
585+
assert len(posted) == 2
586+
assert _sent_keys(rows) == {
587+
"user-daily-overage:org-1:user-1:2026-08-18",
588+
"user-daily-overage:org-1:user-1:2026-08-19",
589+
}
548590

549591

550592
def test_build_alerts_reports_unpriceable_models_once_each() -> None:

0 commit comments

Comments
 (0)