towerops/lib/towerops/workers/alert_notification_worker.ex
Graham McIntire 0350ced8e1 dialyzer: fix remaining 88 warnings — clean dialyzer run
Categories addressed:
- pattern_match / pattern_match_cov (32): remove dead case/with clauses
  that dialyzer proved unreachable from the caller types.
- contract_supertype / extra_range / invalid_contract /
  contract_with_opaque (25): narrow @spec declarations to match actual
  success typings.
- call / call_without_opaque (18): fix bad calls, narrow User.t to
  allow nil for in-memory changeset structs, suppress Ecto.Multi
  opaque-type false positives with targeted @dialyzer directives.
- guard_fail / no_return / unused_fun / unknown_function (13): remove
  dead || fallbacks, simplify always-true params, cascade-resolve
  no_returns via the underlying pattern_match and call fixes.

Real production bug fixed: StormDetector.handle_cast/2 had swapped
`:queue.in` args (`queue |> :queue.in(ts)` which desugars to
`:queue.in(queue, ts)` — wrong argument order). Alert timestamps
were never being enqueued, so storm detection would fail at runtime.
Corrected to `ts |> :queue.in(queue)`.

.dialyzer_ignore.exs: suppress two genuine dep-PLT gaps
(:ranch.get_addr/1 false positive from Bandit's transitive ranch,
and the Cloak.Vault GenServer callback_info on the CI build path).

`mix dialyzer` now: Total errors: 114, Skipped: 114 — passes clean.
Warnings: 88 → 0.
2026-04-21 10:32:42 -05:00

276 lines
7.7 KiB
Elixir

defmodule Towerops.Workers.AlertNotificationWorker do
@moduledoc """
Oban worker for sending alert notifications reliably.
Handles trigger, acknowledge, and resolve notifications with automatic
retries and error tracking.
"""
use Oban.Worker,
queue: :notifications,
max_attempts: 3,
priority: 1
alias Towerops.Alerts
alias Towerops.Alerts.NotificationRateLimiter
alias Towerops.Devices
alias Towerops.OnCall.Escalation
alias Towerops.PagerDuty.Notifier
alias Towerops.Workers.AlertDigestWorker
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"action" => "trigger", "alert_id" => alert_id}}) do
with {:ok, alert} <- fetch_alert(alert_id),
{:ok, device} <- fetch_device(alert.device_id) do
handle_trigger_notification(alert, device, alert_id)
else
{:error, :alert_not_found} ->
Logger.warning("Alert #{alert_id} not found for notification")
:ok
{:error, :device_not_found} ->
Logger.warning("Device not found for alert #{alert_id}")
:ok
end
end
def perform(%Oban.Job{args: %{"action" => "acknowledge", "alert_id" => alert_id}}) do
case fetch_alert(alert_id) do
{:ok, alert} ->
routing = alert_routing_for_alert(alert)
if routing in ["builtin", "both"], do: maybe_acknowledge_incident(alert)
if routing in ["pagerduty", "both"] do
notify_pagerduty_acknowledge(alert, alert_id)
else
:ok
end
{:error, :alert_not_found} ->
Logger.warning("Alert #{alert_id} not found for acknowledge notification")
:ok
end
end
def perform(%Oban.Job{args: %{"action" => "resolve", "alert_id" => alert_id}}) do
case fetch_alert(alert_id) do
{:ok, alert} ->
routing = alert_routing_for_alert(alert)
if routing in ["builtin", "both"], do: maybe_resolve_incident(alert)
if routing in ["pagerduty", "both"] do
notify_pagerduty_resolve(alert, alert_id)
else
:ok
end
{:error, :alert_not_found} ->
Logger.warning("Alert #{alert_id} not found for resolve notification")
:ok
end
end
# Helper to enqueue notification jobs
def enqueue_trigger(alert_id) do
%{action: "trigger", alert_id: alert_id}
|> new()
|> Oban.insert()
end
def enqueue_acknowledge(alert_id) do
%{action: "acknowledge", alert_id: alert_id}
|> new()
|> Oban.insert()
end
def enqueue_resolve(alert_id) do
%{action: "resolve", alert_id: alert_id}
|> new()
|> Oban.insert()
end
defp handle_trigger_notification(alert, device, alert_id) do
if alert.storm_suppressed do
Logger.debug("Skipping notification for storm-suppressed alert #{alert_id}")
:ok
else
send_notifications(alert, device, alert_id)
end
end
defp send_notifications(alert, device, alert_id) do
routing = alert_routing(device)
# Apply per-user rate limiting for built-in notifications
rate_limited_users =
if routing in ["builtin", "both"] do
check_and_apply_rate_limits(alert, device)
else
MapSet.new()
end
pd_result =
if routing in ["pagerduty", "both"] do
notify_pagerduty_trigger(alert, device, alert_id)
else
:ok
end
if routing in ["builtin", "both"] do
maybe_start_escalation(alert, device, rate_limited_users)
end
pd_result
end
defp notify_pagerduty_trigger(alert, device, alert_id) do
case Notifier.notify_trigger(alert, device) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping")
:ok
result ->
result
end
end
defp notify_pagerduty_acknowledge(alert, alert_id) do
case Notifier.notify_acknowledge(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping")
:ok
result ->
result
end
end
defp notify_pagerduty_resolve(alert, alert_id) do
case Notifier.notify_resolve(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping")
:ok
result ->
result
end
end
defp maybe_start_escalation(alert, device, _rate_limited_users) do
policy_id = device.escalation_policy_id || device.organization.default_escalation_policy_id
if policy_id do
case Escalation.start_escalation(alert, policy_id) do
{:ok, _incident} ->
Logger.info("Started built-in escalation for alert #{alert.id}")
{:error, reason} ->
Logger.warning("Failed to start escalation for alert #{alert.id}: #{inspect(reason)}")
end
end
end
# Check rate limits for users who would receive this notification.
# Returns a MapSet of user IDs that are rate-limited (notifications suppressed).
defp check_and_apply_rate_limits(alert, device) do
org_id = device.organization_id
policy_id = device.escalation_policy_id || device.organization.default_escalation_policy_id
policy_id
|> case do
nil -> []
id -> resolve_escalation_targets(id)
end
|> Enum.filter(&should_suppress_for_user?(&1, org_id, alert.id))
|> MapSet.new()
end
defp should_suppress_for_user?(user_id, org_id, alert_id) do
case NotificationRateLimiter.check_rate(user_id, org_id, alert_id) do
:suppress ->
_ = AlertDigestWorker.enqueue(user_id)
true
:allow ->
false
end
end
# Resolve escalation policy targets to user IDs
defp resolve_escalation_targets(policy_id) do
import Ecto.Query
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget
alias Towerops.Repo
# Get targets from first rule (position 0) of the policy
from(t in EscalationTarget,
join: r in EscalationRule,
on: r.id == t.escalation_rule_id,
where: r.escalation_policy_id == ^policy_id,
where: r.position == 0,
where: t.target_type == "user",
select: t.user_id
)
|> Repo.all()
|> Enum.reject(&is_nil/1)
rescue
_ -> []
end
defp maybe_acknowledge_incident(alert) do
case Escalation.find_incident_for_alert(alert.id) do
nil ->
:ok
incident ->
case Escalation.acknowledge_incident(incident.id, nil) do
{:ok, _} -> Logger.info("Acknowledged incident #{incident.id} for alert #{alert.id}")
{:error, reason} -> Logger.warning("Failed to acknowledge incident: #{inspect(reason)}")
end
end
end
defp maybe_resolve_incident(alert) do
case Escalation.find_incident_for_alert(alert.id) do
nil ->
:ok
incident ->
case Escalation.resolve_incident(incident.id, nil) do
{:ok, _} -> Logger.info("Resolved incident #{incident.id} for alert #{alert.id}")
{:error, reason} -> Logger.warning("Failed to resolve incident: #{inspect(reason)}")
end
end
end
# Get alert routing from the device's organization (preloaded)
defp alert_routing(%{organization: %{alert_routing: routing}}) when is_binary(routing), do: routing
defp alert_routing(_device), do: "builtin"
# Get alert routing for acknowledge/resolve (need to look up from alert's device)
defp alert_routing_for_alert(alert) do
case fetch_device(alert.device_id) do
{:ok, device} -> alert_routing(device)
_ -> "builtin"
end
end
defp fetch_alert(alert_id) do
case Alerts.get_alert(alert_id) do
nil -> {:error, :alert_not_found}
alert -> {:ok, alert}
end
end
defp fetch_device(device_id) do
case Devices.get_device(device_id) do
nil -> {:error, :device_not_found}
device -> {:ok, device}
end
end
end