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.
"""
def acknowledge_alert(alert, user_id) do
alert
|> Alert.changeset(%{
acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second),
acknowledged_by_id: user_id
})
|> Repo.update()
result =
alert
|> Alert.changeset(%{
acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second),
acknowledged_by_id: user_id
})
|> 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
@doc """
Resolves an alert.
"""
def resolve_alert(alert) do
alert
|> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)})
|> Repo.update()
result =
alert
|> 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
@doc """

View file

@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_providers ~w(preseem gaiia)
@valid_providers ~w(preseem gaiia pagerduty)
@valid_sync_statuses ~w(never success partial failed)
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
alert_message = get_down_alert_message(device)
{:ok, _alert} =
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
@ -210,6 +210,8 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
message: alert_message
})
Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
@ -229,7 +231,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
defp handle_equipment_up(device, now) do
recovery_message = get_recovery_message(device)
{:ok, _alert} =
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_up,
@ -237,6 +239,8 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
message: recovery_message
})
Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end)
resolve_down_alert(device)
_ =

View file

@ -1756,6 +1756,83 @@ defmodule ToweropsWeb.HelpLive.Index do
</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">
General Integration Features
</h3>

View file

@ -22,6 +22,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
description:
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
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:
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
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("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 load_integrations(organization_id) do

View file

@ -840,6 +840,9 @@
<%= if provider.id == "gaiia" do %>
Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates.
<% 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>
</div>
<% end %>
@ -894,24 +897,32 @@
<.input
field={@integration_form[:api_key]}
type="password"
label="API Key"
placeholder={"Enter your #{provider.name} API key"}
label={if(provider.id == "pagerduty", do: "Integration Key (Routing Key)", else: "API Key")}
placeholder={if(provider.id == "pagerduty", do: "Enter your PagerDuty integration key", else: "Enter your #{provider.name} API key")}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<.input
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)
)
}
/>
<%= if provider.id == "pagerduty" do %>
<p class="text-xs text-gray-500 dark:text-gray-400 -mt-2">
Find this in PagerDuty → Services → Your Service → Integrations → Events API v2
</p>
<% end %>
<%= if provider.id != "pagerduty" do %>
<.input
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 %>
<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