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.
276 lines
7.7 KiB
Elixir
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
|