feat: add PagerDuty Events API v2 integration with 2-way alert sync

This commit is contained in:
Graham McIntire 2026-02-14 11:18:04 -06:00
parent a8339797fa
commit bb4ff232e3
10 changed files with 397 additions and 26 deletions

View file

@ -215,21 +215,41 @@ defmodule Towerops.Alerts do
Acknowledges an alert. Acknowledges an alert.
""" """
def acknowledge_alert(alert, user_id) do def acknowledge_alert(alert, user_id) do
alert result =
|> Alert.changeset(%{ alert
acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second), |> Alert.changeset(%{
acknowledged_by_id: user_id acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second),
}) acknowledged_by_id: user_id
|> Repo.update() })
|> Repo.update()
case result do
{:ok, updated_alert} ->
Task.start(fn -> Towerops.PagerDuty.Notifier.notify_acknowledge(updated_alert) end)
{:ok, updated_alert}
error ->
error
end
end end
@doc """ @doc """
Resolves an alert. Resolves an alert.
""" """
def resolve_alert(alert) do def resolve_alert(alert) do
alert result =
|> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)}) alert
|> Repo.update() |> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)})
|> Repo.update()
case result do
{:ok, updated_alert} ->
Task.start(fn -> Towerops.PagerDuty.Notifier.notify_resolve(updated_alert) end)
{:ok, updated_alert}
error ->
error
end
end end
@doc """ @doc """

View file

@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@valid_providers ~w(preseem gaiia) @valid_providers ~w(preseem gaiia pagerduty)
@valid_sync_statuses ~w(never success partial failed) @valid_sync_statuses ~w(never success partial failed)
schema "integrations" do schema "integrations" do

View file

@ -0,0 +1,112 @@
defmodule Towerops.PagerDuty.Client do
@moduledoc "PagerDuty Events API v2 client."
require Logger
@events_url "https://events.pagerduty.com/v2/enqueue"
def trigger(routing_key, alert, device, site_name, towerops_url) do
send_event(%{
routing_key: routing_key,
event_action: "trigger",
dedup_key: dedup_key(alert),
payload: %{
summary: alert_summary(alert, device),
source: "towerops",
severity: severity(alert.alert_type),
timestamp: DateTime.to_iso8601(alert.triggered_at),
component: device.hostname || device.ip_address,
group: site_name,
class: to_string(alert.alert_type),
custom_details: %{
device_id: device.id,
device_name: device.hostname || device.ip_address,
ip_address: device.ip_address,
site_name: site_name,
alert_type: to_string(alert.alert_type),
message: alert.message,
towerops_url: towerops_url
}
},
links: [%{href: towerops_url, text: "View in TowerOps"}]
})
end
def acknowledge(routing_key, alert) do
send_event(%{
routing_key: routing_key,
event_action: "acknowledge",
dedup_key: dedup_key(alert)
})
end
def resolve(routing_key, alert) do
send_event(%{
routing_key: routing_key,
event_action: "resolve",
dedup_key: dedup_key(alert)
})
end
def test_connection(routing_key) do
test_dedup = "towerops-test-#{System.unique_integer([:positive])}"
case send_event(%{
routing_key: routing_key,
event_action: "trigger",
dedup_key: test_dedup,
payload: %{
summary: "TowerOps integration test",
source: "towerops",
severity: "info",
timestamp: DateTime.to_iso8601(DateTime.utc_now())
}
}) do
{:ok, _} ->
send_event(%{
routing_key: routing_key,
event_action: "resolve",
dedup_key: test_dedup
})
error ->
error
end
end
defp send_event(body) do
case Req.post(@events_url, json: body) do
{:ok, %{status: status, body: resp_body}} when status in [200, 202] ->
{:ok, resp_body}
{:ok, %{status: 400, body: resp_body}} ->
{:error, {:bad_request, resp_body["message"] || "Invalid event"}}
{:ok, %{status: 429}} ->
{:error, :rate_limited}
{:ok, %{status: status}} ->
{:error, {:unexpected_status, status}}
{:error, error} ->
Logger.warning("PagerDuty API error: #{inspect(error)}")
{:error, error}
end
end
defp dedup_key(alert), do: "towerops-alert-#{alert.id}"
defp alert_summary(alert, device) do
name = device.hostname || device.ip_address
case alert.alert_type do
:device_down -> "Device down: #{name}"
:device_up -> "Device recovered: #{name}"
other -> "Alert: #{other} on #{name}"
end
end
defp severity(:device_down), do: "critical"
defp severity(:device_up), do: "info"
defp severity(_), do: "warning"
end

View file

@ -0,0 +1,84 @@
defmodule Towerops.PagerDuty.Notifier do
@moduledoc "Sends alert events to PagerDuty when integration is configured."
alias Towerops.Integrations
alias Towerops.PagerDuty.Client
alias Towerops.Repo
require Logger
def notify_trigger(alert, device) do
with {:ok, routing_key} <- get_routing_key(device.organization_id) do
site_name = get_site_name(device)
towerops_url = device_url(device)
case Client.trigger(routing_key, alert, device, site_name, towerops_url) do
{:ok, _} ->
Logger.info("PagerDuty incident triggered for alert #{alert.id}")
:ok
{:error, reason} ->
Logger.warning("PagerDuty trigger failed for alert #{alert.id}: #{inspect(reason)}")
:error
end
end
end
def notify_acknowledge(alert) do
alert = Repo.preload(alert, device: :site)
with {:ok, routing_key} <- get_routing_key(alert.device.organization_id) do
case Client.acknowledge(routing_key, alert) do
{:ok, _} ->
Logger.info("PagerDuty incident acknowledged for alert #{alert.id}")
:ok
{:error, reason} ->
Logger.warning(
"PagerDuty acknowledge failed for alert #{alert.id}: #{inspect(reason)}"
)
:error
end
end
end
def notify_resolve(alert) do
alert = Repo.preload(alert, device: :site)
with {:ok, routing_key} <- get_routing_key(alert.device.organization_id) do
case Client.resolve(routing_key, alert) do
{:ok, _} ->
Logger.info("PagerDuty incident resolved for alert #{alert.id}")
:ok
{:error, reason} ->
Logger.warning("PagerDuty resolve failed for alert #{alert.id}: #{inspect(reason)}")
:error
end
end
end
defp get_routing_key(organization_id) do
case Integrations.get_integration(organization_id, "pagerduty") do
{:ok, %{enabled: true, credentials: %{"api_key" => key}}} when key != "" ->
{:ok, key}
_ ->
{:error, :not_configured}
end
end
defp get_site_name(device) do
device = Repo.preload(device, :site)
case device.site do
nil -> "Unknown Site"
site -> site.name
end
end
defp device_url(device) do
ToweropsWeb.Endpoint.url() <> "/devices/#{device.id}"
end
end

View file

@ -202,7 +202,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
defp create_device_down_alert(device, now) do defp create_device_down_alert(device, now) do
alert_message = get_down_alert_message(device) alert_message = get_down_alert_message(device)
{:ok, _alert} = {:ok, alert} =
Alerts.create_alert(%{ Alerts.create_alert(%{
device_id: device.id, device_id: device.id,
alert_type: :device_down, alert_type: :device_down,
@ -210,6 +210,8 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
message: alert_message message: alert_message
}) })
Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end)
_ = _ =
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Towerops.PubSub, Towerops.PubSub,
@ -229,7 +231,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
defp handle_equipment_up(device, now) do defp handle_equipment_up(device, now) do
recovery_message = get_recovery_message(device) recovery_message = get_recovery_message(device)
{:ok, _alert} = {:ok, alert} =
Alerts.create_alert(%{ Alerts.create_alert(%{
device_id: device.id, device_id: device.id,
alert_type: :device_up, alert_type: :device_up,
@ -237,6 +239,8 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
message: recovery_message message: recovery_message
}) })
Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end)
resolve_down_alert(device) resolve_down_alert(device)
_ = _ =

View file

@ -1756,6 +1756,83 @@ defmodule ToweropsWeb.HelpLive.Index do
</div> </div>
</div> </div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
PagerDuty
</h3>
<p class="text-gray-600 dark:text-gray-400">
<a href="https://www.pagerduty.com" class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300">PagerDuty</a>
is an incident management and on-call alerting platform. The PagerDuty integration provides
2-way alert sync when a device goes down in Towerops, a PagerDuty incident is automatically
triggered. Acknowledging or resolving the alert in Towerops updates the PagerDuty incident as well.
</p>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">
How It Works
</h4>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside">
<li>Device goes down PagerDuty incident triggered (critical severity)</li>
<li>Alert acknowledged in Towerops PagerDuty incident acknowledged</li>
<li>Device recovers / alert resolved PagerDuty incident resolved</li>
</ul>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">
Configuration
</h4>
<div class="space-y-4">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Create an Events API v2 Integration in PagerDuty
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
In PagerDuty, go to <.code>Services</.code> select your service <.code>Integrations</.code> tab <.code>Add Integration</.code> choose <.code>Events API v2</.code>. Copy the <.code>Integration Key</.code> (routing key).
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Configure in Towerops
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Navigate to <.code>Organization Settings</.code> <.code>Integrations</.code> tab click <.code>Configure</.code> on PagerDuty. Paste your integration key and test the connection.
</p>
</div>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Event-Driven
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
Unlike other integrations, PagerDuty is event-driven there is no periodic sync.
Alerts are sent to PagerDuty in real time as they occur. No polling or sync interval is needed.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
General Integration Features General Integration Features
</h3> </h3>

View file

@ -22,6 +22,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
description: description:
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.", "Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
icon: "hero-user-group" icon: "hero-user-group"
},
%{
id: "pagerduty",
name: "PagerDuty",
description:
"Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
icon: "hero-bell-alert"
} }
] ]

View file

@ -26,6 +26,13 @@ defmodule ToweropsWeb.Org.SettingsLive do
description: description:
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.", "Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
icon: "hero-user-group" icon: "hero-user-group"
},
%{
id: "pagerduty",
name: "PagerDuty",
description:
"Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
icon: "hero-bell-alert"
} }
] ]
@ -395,6 +402,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key) defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key)
defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key) defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key)
defp test_provider_connection("pagerduty", api_key),
do: Towerops.PagerDuty.Client.test_connection(api_key)
defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"} defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"}
defp load_integrations(organization_id) do defp load_integrations(organization_id) do

View file

@ -840,6 +840,9 @@
<%= if provider.id == "gaiia" do %> <%= if provider.id == "gaiia" do %>
Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates. Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates.
<% end %> <% end %>
<%= if provider.id == "pagerduty" do %>
Event-driven — alerts are sent to PagerDuty in real time when devices go down, are acknowledged, or recover.
<% end %>
</p> </p>
</div> </div>
<% end %> <% end %>
@ -894,24 +897,32 @@
<.input <.input
field={@integration_form[:api_key]} field={@integration_form[:api_key]}
type="password" type="password"
label="API Key" label={if(provider.id == "pagerduty", do: "Integration Key (Routing Key)", else: "API Key")}
placeholder={"Enter your #{provider.name} API key"} placeholder={if(provider.id == "pagerduty", do: "Enter your PagerDuty integration key", else: "Enter your #{provider.name} API key")}
autocomplete="off" autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")} value={get_credential(@integrations[provider.id], "api_key")}
/> />
<.input <%= if provider.id == "pagerduty" do %>
field={@integration_form[:sync_interval_minutes]} <p class="text-xs text-gray-500 dark:text-gray-400 -mt-2">
type="number" Find this in PagerDuty → Services → Your Service → Integrations → Events API v2
label="Sync interval (minutes)" </p>
min="5" <% end %>
value={
if(@integrations[provider.id], <%= if provider.id != "pagerduty" do %>
do: @integrations[provider.id].sync_interval_minutes, <.input
else: if(provider.id == "gaiia", do: 15, else: 10) field={@integration_form[:sync_interval_minutes]}
) type="number"
} label="Sync interval (minutes)"
/> min="5"
value={
if(@integrations[provider.id],
do: @integrations[provider.id].sync_interval_minutes,
else: if(provider.id == "gaiia", do: 15, else: 10)
)
}
/>
<% end %>
<%= if @test_result do %> <%= if @test_result do %>
<div class={[ <div class={[

View file

@ -0,0 +1,45 @@
defmodule Towerops.PagerDuty.ClientTest do
use ExUnit.Case, async: true
alias Towerops.PagerDuty.Client
describe "trigger/5" do
test "builds correct event body" do
alert = %{
id: "abc-123",
alert_type: :device_down,
triggered_at: ~U[2024-01-15 10:30:00Z],
message: "Device is not responding to ping"
}
device = %{
id: "dev-456",
hostname: "router-01",
ip_address: "10.0.0.1",
organization_id: "org-789"
}
# We can't easily test the HTTP call without mocking,
# but we verify the function exists and accepts the right arity
assert is_function(&Client.trigger/5)
end
end
describe "acknowledge/2" do
test "accepts routing key and alert" do
assert is_function(&Client.acknowledge/2)
end
end
describe "resolve/2" do
test "accepts routing key and alert" do
assert is_function(&Client.resolve/2)
end
end
describe "test_connection/1" do
test "accepts routing key" do
assert is_function(&Client.test_connection/1)
end
end
end