Notification Failures Runbook
Triage for the in-app AppNotification pipeline (#915): users not receiving notifications, the bell/inbox not updating in real time, badge counts not clearing, or notifications missing entirely. Pipeline reference: features/notifications.md.
Mental model. Two surfaces, one DTO. The REST store (
app_notificationstable +/notifications) is the source of truth and survives reconnect. The Socket.io push (/notificationsnamespace,notification:newevent) is a best-effort latency optimization with no replay. If the REST store has the row but the push didn't arrive, the bug is in the real-time leg; if the store has no row, the bug is upstream in the producer.
1. Signalsโ
There are no dedicated notification metrics โ the observability surface is structured logs from NotificationsGateway and RedisIoAdapter. Watch for:
| Log line (source) | Meaning |
|---|---|
REDIS_URL not set (RedisIoAdapter) | Gateway fell back to the in-memory adapter โ cross-pod push is broken under active-active |
Failed to initialize Redis adapter / pub-sub error (RedisIoAdapter) | Redis reachable issue โ cross-pod delivery degraded |
Auth failed for client โฆ / connected without token (NotificationsGateway) | Client JWT rejected at the WS handshake โ never joins user:<id> room |
token missing user id (NotificationsGateway) | Token verified but has no userId/sub โ disconnected |
2. Decide which leg is brokenโ
Ask the affected user (or check the DB) whether the notification exists in history:
-- Does the row exist at all? (source of truth)
SELECT id, event_type, read_at, created_at
FROM app_notifications
WHERE recipient_user_id = '<userId>'
ORDER BY created_at DESC
LIMIT 20;
- Row is present, but it didn't pop in real time โ real-time leg. Go to ยง3.
- Row is absent โ the producer never called
NotificationsService.create(). Go to ยง4. - Row is present and
read_atis set but the badge still shows it โ frontend state / dedup. Go to ยง5.
3. Real-time push not arrivingโ
The push only reaches a socket that (a) authenticated and (b) joined user:<recipientUserId>, and the emit only crosses pods through Redis.
- Redis adapter. Confirm
REDIS_URLis set on every API pod. AREDIS_URL not setwarning means a pod is on the in-memory adapter: an emit on pod A never reaches the user's socket on pod B. This is a deploy/config bug โ restoreREDIS_URL(root CLAUDE.md mandates it in prod, #1354). Verify Redis pub/sub connectivity from a pod. - Handshake / room join. Look for
Auth failed for client โฆortoken missing user idaround the user's connect attempts. A rejected handshake means the socket never joineduser:<id>and will receive nothing. Cause is usually an expired/short JWT or a clock-skewedJWT_SECRETmismatch โ have the user re-auth; confirmJWT_SECRETparity across pods. - Expected, not a bug: no replay. If the user was disconnected when the notification fired, the event is gone โ there is no backfill on reconnect. The frontend reconciles by calling
GET /notificationson mount/reconnect; confirm that fetch is happening (Network tab). If the row is inGET /notificationsbut the bell didn't update on reconnect, it's a frontend reconcile bug (ยง5), not a delivery loss.
4. Notification never created (producer side)โ
The row is absent, so the producer didn't persist it. Producers create best-effort and swallow per-recipient errors so one failure doesn't block the batch โ a silent miss is possible.
Known producers (extend this table as new ones land):
event_type | Producer | Source |
|---|---|---|
billing.payment_failed | BillingPaymentFailedNotifier | api/src/billing/infrastructure/billing-payment-failed.notifier.ts |
workspace_provisioning_failed | WorkspaceProvisioningProcessor | api/src/workspace-lifecycle/workspace-provisioning.processor.ts |
- Confirm the upstream event actually fired (e.g. the Lago
payment_failedwebhook was received; the BullMQ provisioning job actually exhausted retries). - Confirm the producer resolved a recipient
userIdโ if the org has no assigned AM / no billing-responsible member, there is no one to notify andcreate()is never called. That's a data gap (assign an AM), not a pipeline failure. - Check producer logs for caught exceptions around the
notifications.create()call.
5. Badge / inbox state wrongโ
- Badge won't clear after read.
PATCH /notifications/:id/readis idempotent and only flipsread_atonce. Confirm the PATCH returns 200 (not 403 โ that means the caller isn't the owner; not 404 โ wrong id). The unread count is derived client-side fromreadAt === null, so a stale list inNotificationContextis the usual cause โ a refetch (GET /notifications) should reconcile. - Duplicate rows in the bell. The frontend dedupes incoming
notification:newagainst the REST list byid. True DB duplicates (two rows, two ids) mean a producer fired twice โ the module does not dedup by(recipient, type); idempotency is the producer's responsibility (e.g. a re-run BullMQ job). Decide at the producer whether the duplicate is acceptable (often it is โ it's just a heads-up).
6. Escalationโ
- Cross-pod delivery (Redis) issues that aren't a simple
REDIS_URLconfig miss โ infra on-call; see bullmq-queue-backlog.md and observability.md for Redis health. - Producer-side gaps (missing AM, webhook not received) โ the owning module's on-call (billing / workspace-lifecycle).
- Pipeline/contract questions โ features/notifications.md and api/reference.md โ Notifications.