Runbook — console-prod H12 + WORKER TIMEOUT Slack alerting
System: console — Heroku log-drain alerting service
Issue: #1345
Incident that prompted this: docs/incidents/2026-05-07-console-prod-worker-thrash.md
was the originally-cited motivation for this alerting, but that RCA
document was never actually written (no such file was ever created in
docs/incidents/). The confirmed, documented recurrence of this exact
failure class is docs/incidents/2026-07-24-console-billing-h12-db-pool-exhaustion.md
— see the Root cause + remediation (2026-07-24) section below.
Implementation: Option B — Heroku log drain webhook (Flask endpoint + Slack DM)
Flag: FLAG_CONSOLE_HEROKU_LOG_DRAIN_ALERTING (default off)
What this does
A Heroku log drain delivers syslog log lines from raxx-console-prod to the
endpoint POST /api/internal/heroku-log-drain in the console app. The
endpoint parses each line for:
| Pattern | Threshold | Window | Alert destination |
|---|---|---|---|
code=H12 |
> 3 events | 5 minutes | Operator Slack DM (D0AJ7K184TV) |
WORKER TIMEOUT |
> 2 events | 2 minutes | Operator Slack DM (D0AJ7K184TV) |
Both alert types include:
- A description of the breach (count, window, threshold)
- App name (raxx-console-prod)
- UTC timestamp
- Runbook link: docs/ops/runbooks/console-status-poller.md — Failure mode H
A 5-minute cooldown per alert type prevents duplicate messages during a sustained storm.
First-time setup (operator action required after merge)
1. Generate a drain HMAC secret
python3 -c "import secrets; print(secrets.token_hex(32))"
Store the output as HEROKU_LOG_DRAIN_HMAC_SECRET in Infisical at
/MooseQuest/console-prod/HEROKU_LOG_DRAIN_HMAC_SECRET and in SSM
(/raxx/console-prod/HEROKU_LOG_DRAIN_HMAC_SECRET) per
feedback_aws_workloads_use_ssm_not_vault.md.
2. Set env vars on the Heroku app
SECRET=$(infisical secrets get HEROKU_LOG_DRAIN_HMAC_SECRET \
--path /MooseQuest/console-prod --env prod --plain)
heroku config:set HEROKU_LOG_DRAIN_HMAC_SECRET="$SECRET" \
--app raxx-console-prod >/dev/null 2>&1
heroku config:set FLAG_CONSOLE_HEROKU_LOG_DRAIN_ALERTING=true \
--app raxx-console-prod >/dev/null 2>&1
3. Compute the drain token (one-time)
The drain URL uses ?drain_token=<HMAC(secret, logplex_token)>. You cannot
know the Logplex-Drain-Token until after the drain is registered, so this is a
two-step process:
Step A — Register a placeholder drain first to get the Logplex-Drain-Token:
heroku drains:add \
"https://console.raxx.app/api/internal/heroku-log-drain?drain_token=placeholder" \
--app raxx-console-prod
Heroku will assign a Logplex token. Retrieve it:
heroku drains --app raxx-console-prod
# Output:
# https://console.raxx.app/api/internal/heroku-log-drain?drain_token=placeholder (d.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
The token in parentheses (e.g. d.abc123...) is the Logplex-Drain-Token.
Step B — Compute the real drain_token and update the drain URL:
LOGPLEX_TOKEN="d.abc123..." # paste the token from the output above
SECRET=$(heroku config:get HEROKU_LOG_DRAIN_HMAC_SECRET --app raxx-console-prod)
DRAIN_TOKEN=$(python3 -c "
import hashlib, hmac
print(hmac.new('$SECRET'.encode(), '$LOGPLEX_TOKEN'.encode(), hashlib.sha256).hexdigest())
")
echo "drain_token=$DRAIN_TOKEN"
Step C — Remove the placeholder drain and register with the correct token:
# Find the drain ID first
heroku drains --app raxx-console-prod
# Remove the placeholder
heroku drains:remove <drain_id> --app raxx-console-prod
# Register with the correct drain_token
heroku drains:add \
"https://console.raxx.app/api/internal/heroku-log-drain?drain_token=$DRAIN_TOKEN" \
--app raxx-console-prod
4. Verify the drain is delivering
# Tail logs and look for the drain receipt line
heroku logs --tail --app raxx-console-prod | grep heroku_log_drain
Expected output when a log line is delivered:
app[web.1]: heroku_log_drain: processed N lines from logplex delivery
If you see 401 in Heroku's drain delivery log, the HMAC token is wrong —
re-run Step B with the correct LOGPLEX_TOKEN.
Smoke test (after setup)
Run manually from the console machine or a developer workstation with access:
./scripts/agent-tools/run_smoke.sh
For a targeted drain smoke test, use curl to POST a synthetic H12 line to the
endpoint. First, compute a valid drain_token for a test Logplex token:
SECRET=$(heroku config:get HEROKU_LOG_DRAIN_HMAC_SECRET --app raxx-console-prod)
TEST_LOGPLEX="t.test-smoke-$(date +%s)"
DRAIN_TOKEN=$(python3 -c "
import hashlib, hmac
print(hmac.new('$SECRET'.encode(), '$TEST_LOGPLEX'.encode(), hashlib.sha256).hexdigest())
")
curl -sS -X POST \
"https://console.raxx.app/api/internal/heroku-log-drain?drain_token=$DRAIN_TOKEN" \
-H "Content-Type: application/logplex-1" \
-H "Logplex-Drain-Token: $TEST_LOGPLEX" \
-d '2026-05-07T08:06:12Z heroku[router]: at=error code=H12 desc="Request timeout"' \
-w "\nHTTP %{http_code}\n"
Expected: HTTP 204
To trigger a full alert, send 4+ H12 lines in rapid succession and check for a Slack DM in the operator channel.
Implementation decision (Option B selected)
This implementation uses Option B (Heroku log drain webhook). The decision
rationale:
- The console app has no Sentry SDK installed (Sentry integration is via the
sentry_backend flag in Raptor, not Antlers/console).
- Heroku's built-in H12 metric threshold alerts (Option C) don't support
WORKER TIMEOUT pattern matching (a Gunicorn-level log event, not a Heroku
router error code).
- Option B reuses existing slack_notify.py infrastructure and requires no
new dependencies.
Turning the alert off
heroku config:set FLAG_CONSOLE_HEROKU_LOG_DRAIN_ALERTING=false \
--app raxx-console-prod >/dev/null 2>&1
The drain endpoint returns 501 when the flag is off. Heroku will log drain delivery failures but the drain remains registered for when you re-enable.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No alerts firing despite H12 storm | Flag off, Slack token missing, drain not registered, wrong drain_token | Check flag, SLACK_BOT_TOKEN, drain URL HMAC |
HTTP 401 from drain delivery |
drain_token in URL doesn't match HMAC(secret, logplex_token) |
Re-compute drain_token and re-register drain |
HTTP 501 from drain delivery |
FLAG_CONSOLE_HEROKU_LOG_DRAIN_ALERTING=false or HEROKU_LOG_DRAIN_HMAC_SECRET unset |
Set flag to true, set secret |
| Alert fires for non-prod apps | Log drain is registered against the wrong Heroku app | Only register on raxx-console-prod |
| Duplicate alerts every 5+ minutes | Cooldown period expired; storm is sustained | Normal; expected behavior for a sustained H12 storm |
Root cause + remediation (2026-07-24)
Confirmed root cause of the H12/WORKER TIMEOUT failure class this alerting
watches for: console/app/__init__.py set SQLALCHEMY_DATABASE_URI but no
SQLALCHEMY_ENGINE_OPTIONS, so SQLAlchemy's un-tuned defaults applied
(pool_size=5, max_overflow=10 — up to 15 possible connections per
Gunicorn worker, since each worker process owns its own pool). Against
heroku-postgresql:essential-0 (hard cap: 20 total connections),
WEB_CONCURRENCY=4 workers could demand up to 4 x 15 = 60 connections — 3x
the plan's ceiling. A polling/traffic burst exhausted the pool; the affected
worker(s) blocked on connection checkout past Gunicorn's 30s timeout and were
SIGKILL'd mid-request, producing the H12 this runbook's alerting fires on.
Full RCA: docs/incidents/2026-07-24-console-billing-h12-db-pool-exhaustion.md.
Immediate mitigation (operator, out-of-band): WEB_CONCURRENCY set
4->2 on raxx-console-prod, roughly halving worst-case connection demand.
This reduces exposure but does not eliminate it on its own (2 workers x 15 =
30 still exceeds the 20-connection cap) — it is a mitigation, not a fix, and
does not protect against a future bump back to WEB_CONCURRENCY=4.
Durable fix (code, same incident): console/app/__init__.py now sets
explicit, bounded SQLALCHEMY_ENGINE_OPTIONS:
| Option | Default | Env override | Notes |
|---|---|---|---|
pool_size |
2 | DB_POOL_SIZE |
postgres backend only |
max_overflow |
3 | DB_MAX_OVERFLOW |
postgres backend only |
pool_recycle |
300s | DB_POOL_RECYCLE_SECONDS |
postgres backend only |
pool_pre_ping |
True |
n/a | all backends; drops dead connections instead of erroring on use |
At WEB_CONCURRENCY=4, worst case is now 4 x (2+3) = 20 — exactly at the
essential-0 cap, never over it, independent of how WEB_CONCURRENCY is set
going forward. pool_size/max_overflow are only applied for the postgres
backend (sqlite, used locally/CI, is unaffected — sqlite's default pool class
for :memory: URLs does not accept those kwargs).
If H12/WORKER TIMEOUT alerts fire again after this fix: first check
whether WEB_CONCURRENCY has been raised, and whether DB_POOL_SIZE /
DB_MAX_OVERFLOW env vars have been overridden to widen the pool back past
the plan's connection cap. Second, check heroku pg:info -a raxx-console-prod
for connection-count pressure from a source other than the console web
dynos (e.g. worker dynos, one-off heroku run / pg:psql sessions,
external monitoring). See Action item 3 in the RCA for the leading-indicator
connection-count alert that should catch this before it reaches H12.
References
- Implementation PR: #1345
- Referenced-but-never-written prior RCA (dead link):
docs/incidents/2026-05-07-console-prod-worker-thrash.md - Confirmed root cause + durable fix RCA:
docs/incidents/2026-07-24-console-billing-h12-db-pool-exhaustion.md - Durable fix implementation:
console/app/__init__.py(SQLALCHEMY_ENGINE_OPTIONS) - Worker-thrash runbook (failure mode H):
docs/ops/runbooks/console-status-poller.md - Slack notify service:
console/app/services/slack_notify.py - Alert service:
console/app/services/heroku_log_drain_alert.py - Blueprint:
console/app/blueprints/heroku_log_drain.py