Skip to content

Alerting: from a report to a notification

There is no rules engine and no message queue in this path. It's worth being direct about that up front, because "alerting" suggests more machinery than exists. What follows is the real path, and where it falls short.

Two triggers, not one

1. Something happens right now — an incident or hazard gets assigned to someone, a corrective action is created, an interview is scheduled. The handler that processes the request does its main write, then fires off a notification in a separate goroutine so the HTTP response doesn't wait on an email round-trip.

2. Time passes — a background job runs once every 24 hours and scans for things nobody has acted on: incidents still open with an assignee, corrective actions past their due date, and actions due within 48 hours. Each one it finds gets a notification, same as trigger 1.

Neither trigger involves "detection rules" in the sense of configurable thresholds or a rules DSL. Trigger 1 is a handful of hardcoded call sites, one per event type. Trigger 2 is three hardcoded SQL conditions in one function.

The path for an immediate event

sequenceDiagram
    participant Client
    participant Handler
    participant Service as Domain Service
    participant DB as PostgreSQL
    participant Goroutine as go func() (detached)
    participant NotifSvc as Notification Service
    participant Mail as SMTP

    Client->>Handler: POST /incidents/:id/assign
    Handler->>Service: AssignIncidentToUser(...)
    Service->>DB: UPDATE incidents SET assigned_to = ...
    DB-->>Service: ok
    Service-->>Handler: updated incident
    Handler->>Client: 200 OK (response sent — does not wait below)

    Handler->>Goroutine: go NotifyIncidentAssignment(incident, assignee)
    Note over Handler,Goroutine: fire-and-forget: no error returned to the caller,<br/>no retry, no record that this even started
    Goroutine->>NotifSvc: SendNotification(...)
    NotifSvc->>DB: INSERT INTO notifications (...)
    NotifSvc->>Mail: send templated email
    alt SMTP succeeds
        Mail-->>NotifSvc: 250 OK
    else SMTP fails or times out
        Mail-->>NotifSvc: error
        NotifSvc->>NotifSvc: log the error, return nil anyway
        Note over NotifSvc: the notification row already exists;<br/>nothing marks the email as failed
    end

The notification row is always written first — that part is durable. It's the email send, and the entire goroutine itself, that has no safety net: if the process restarts between the go func() call and the SMTP round-trip completing, that notification simply never gets emailed, and there is no record that it was supposed to be.

The path for the daily reminder scan

sequenceDiagram
    participant Ticker as 24h ticker (goroutine, started at boot)
    participant NotifSvc as Notification Service
    participant DB as PostgreSQL
    participant Mail as SMTP

    loop every 24 hours
        Ticker->>NotifSvc: CheckAndSendReminders()
        NotifSvc->>DB: SELECT unresolved incidents with an assignee
        NotifSvc->>DB: SELECT overdue corrective actions
        NotifSvc->>DB: SELECT actions due within 48h
        loop for each row found
            NotifSvc->>DB: INSERT INTO notifications
            NotifSvc->>Mail: send email
        end
    end

This job also tests the SMTP connection once at startup and logs if that fails, but that check isn't wired to anything — a broken SMTP connection at boot doesn't stop the ticker from trying anyway.

Where the queue actually is (and isn't)

There is exactly one real, bounded, worker-backed queue in the system: the request-logging middleware that feeds the analytics rollup. It's a fixed-size channel with a small pool of worker goroutines, and it drops a new entry outright if the queue is full — a deliberate choice, because the job it's protecting is analytics, and losing an occasional API-usage log line is an acceptable cost for never blocking or slowing down a real request.

That queue has nothing to do with alerting. Notifications don't go through it, don't have a queue, and don't have a drop policy — they either send inline in a detached goroutine, or they don't send at all with no trace of the failure.

What this costs, concretely

  • No retry. An SMTP timeout is caught, logged, and discarded. The notification row exists; the email doesn't go out; nothing revisits it.
  • No durability across a restart. The notification-send goroutine holds no state anywhere except in memory between the DB write and the SMTP call. A deploy or a crash in that window loses the send silently.
  • No backpressure or rate limiting. A single action that fans out to many recipients (for example, closing an incident that many people are watching) opens one goroutine and one SMTP connection per recipient, unthrottled.
  • No visibility. There's no "failed notifications" view, no metric, no alert-on-alerting-failure. The only trace of an SMTP failure is a log line.

See decisions.md for the fire-and-forget notification design as a decision that didn't hold up, and architecture.md for what a durable-outbox replacement would look like.