towerops/lib/towerops/monitoring.ex

255 lines
8.2 KiB
Elixir

defmodule Towerops.Monitoring do
@moduledoc """
The Monitoring context.
"""
import Ecto.Query
alias Ecto.Adapters.SQL
alias Towerops.Monitoring.Check
alias Towerops.Repo
@doc """
Creates a monitoring check.
"""
def create_check(attrs) do
%Check{}
|> Check.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns the list of checks for an device.
"""
def list_devices_checks(device_id, limit \\ 100) do
Repo.all(from(c in Check, where: c.device_id == ^device_id, order_by: [desc: c.checked_at], limit: ^limit))
end
@doc """
Returns the latest check for an device.
"""
def get_latest_check(device_id) do
Repo.one(from(c in Check, where: c.device_id == ^device_id, order_by: [desc: c.checked_at], limit: 1))
end
@doc """
Deletes old monitoring checks older than the given date.
"""
def delete_old_checks(older_than_date) do
Repo.delete_all(from(c in Check, where: c.checked_at < ^older_than_date))
end
@doc """
Gets hourly statistics for device over a time range.
Uses TimescaleDB continuous aggregates for performance when available,
falls back to raw query calculation otherwise.
"""
def get_hourly_stats(device_id, start_time, end_time) do
# Try continuous aggregate first (much faster for large datasets)
query = """
SELECT
bucket,
total_checks,
successful_checks,
failed_checks,
ROUND(avg_response_time_ms::numeric, 2) as avg_response_time_ms,
min_response_time_ms,
max_response_time_ms,
ROUND(100.0 * successful_checks / NULLIF(total_checks, 0), 2) as uptime_percentage
FROM monitoring_checks_hourly
WHERE device_id = $1::uuid
AND bucket >= $2
AND bucket <= $3
ORDER BY bucket ASC
"""
case SQL.query(Repo, query, [Ecto.UUID.dump!(device_id), start_time, end_time]) do
{:ok, %{rows: rows} = result} when rows != [] ->
result
{:ok, _empty_result} ->
# Continuous aggregate may not have recent data yet, fall back to raw query
get_hourly_stats_raw(device_id, start_time, end_time)
{:error, _} ->
# Fallback to raw query if continuous aggregate doesn't exist
get_hourly_stats_raw(device_id, start_time, end_time)
end
end
# Fallback for environments without TimescaleDB continuous aggregates
defp get_hourly_stats_raw(device_id, start_time, end_time) do
query = """
SELECT
date_trunc('hour', checked_at) as bucket,
COUNT(*) as total_checks,
COUNT(*) FILTER (WHERE status = 'success') as successful_checks,
COUNT(*) FILTER (WHERE status IN ('failure', 'timeout')) as failed_checks,
ROUND(AVG(response_time_ms)::numeric, 2) as avg_response_time_ms,
MIN(response_time_ms) as min_response_time_ms,
MAX(response_time_ms) as max_response_time_ms,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage
FROM monitoring_checks
WHERE device_id = $1::uuid
AND checked_at >= $2
AND checked_at <= $3
GROUP BY bucket
ORDER BY bucket ASC
"""
SQL.query!(
Repo,
query,
[Ecto.UUID.dump!(device_id), start_time, end_time]
)
end
@doc """
Gets daily statistics for device over a time range.
Uses TimescaleDB continuous aggregates for performance when available,
falls back to raw query calculation otherwise.
"""
def get_daily_stats(device_id, start_time, end_time) do
# Try continuous aggregate first (much faster for large datasets)
query = """
SELECT
bucket,
total_checks,
successful_checks,
failed_checks,
ROUND(avg_response_time_ms::numeric, 2) as avg_response_time_ms,
min_response_time_ms,
max_response_time_ms,
ROUND(100.0 * successful_checks / NULLIF(total_checks, 0), 2) as uptime_percentage
FROM monitoring_checks_daily
WHERE device_id = $1::uuid
AND bucket >= $2
AND bucket <= $3
ORDER BY bucket ASC
"""
case SQL.query(Repo, query, [Ecto.UUID.dump!(device_id), start_time, end_time]) do
{:ok, %{rows: rows} = result} when rows != [] ->
result
{:ok, _empty_result} ->
# Continuous aggregate may not have recent data yet, fall back to raw query
get_daily_stats_raw(device_id, start_time, end_time)
{:error, _} ->
# Fallback to raw query if continuous aggregate doesn't exist
get_daily_stats_raw(device_id, start_time, end_time)
end
end
# Fallback for environments without TimescaleDB continuous aggregates
defp get_daily_stats_raw(device_id, start_time, end_time) do
query = """
SELECT
date_trunc('day', checked_at) as bucket,
COUNT(*) as total_checks,
COUNT(*) FILTER (WHERE status = 'success') as successful_checks,
COUNT(*) FILTER (WHERE status IN ('failure', 'timeout')) as failed_checks,
ROUND(AVG(response_time_ms)::numeric, 2) as avg_response_time_ms,
MIN(response_time_ms) as min_response_time_ms,
MAX(response_time_ms) as max_response_time_ms,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage
FROM monitoring_checks
WHERE device_id = $1::uuid
AND checked_at >= $2
AND checked_at <= $3
GROUP BY bucket
ORDER BY bucket ASC
"""
SQL.query!(
Repo,
query,
[Ecto.UUID.dump!(device_id), start_time, end_time]
)
end
@doc """
Gets uptime percentage for device over the last N days.
Uses TimescaleDB continuous aggregates for performance when available,
falls back to raw query calculation otherwise.
"""
def get_uptime_percentage(device_id, days_ago \\ 30) do
start_time = DateTime.add(DateTime.utc_now(), -days_ago * 24 * 60 * 60, :second)
# Try continuous aggregate first (aggregates daily data for fast calculation)
query = """
SELECT
ROUND(100.0 * SUM(successful_checks) / NULLIF(SUM(total_checks), 0), 2) as uptime_percentage
FROM monitoring_checks_daily
WHERE device_id = $1::uuid
AND bucket >= $2
"""
case SQL.query(Repo, query, [Ecto.UUID.dump!(device_id), start_time]) do
{:ok, %{rows: [[%Decimal{} = uptime]]}} ->
uptime |> Decimal.to_float() |> Float.round(2)
{:ok, %{rows: [[nil]]}} ->
# Continuous aggregate may not have recent data yet, fall back to raw query
get_uptime_percentage_raw(device_id, start_time)
{:ok, %{rows: []}} ->
# Continuous aggregate may not have recent data yet, fall back to raw query
get_uptime_percentage_raw(device_id, start_time)
{:error, _} ->
# Fallback to raw query if continuous aggregate doesn't exist
get_uptime_percentage_raw(device_id, start_time)
end
end
# Fallback for environments without TimescaleDB continuous aggregates
defp get_uptime_percentage_raw(device_id, start_time) do
query = """
SELECT
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage
FROM monitoring_checks
WHERE device_id = $1::uuid
AND checked_at >= $2
"""
case SQL.query!(Repo, query, [Ecto.UUID.dump!(device_id), start_time]) do
%{rows: [[%Decimal{} = uptime]]} ->
uptime |> Decimal.to_float() |> Float.round(2)
_ ->
nil
end
end
@doc """
Gets latency data for graphing ping response times.
Returns successful monitoring checks with response times for the specified time range.
Failed checks are excluded as they have no latency data.
## Options
* `:since` - Only return checks after this datetime
* `:limit` - Maximum number of checks to return (default: 2880)
## Examples
iex> get_latency_data(device_id, since: DateTime.utc_now() |> DateTime.add(-24, :hour), limit: 100)
[%Check{response_time_ms: 50, checked_at: ~U[2025-12-21 12:00:00Z]}, ...]
"""
def get_latency_data(device_id, opts \\ []) do
since = Keyword.get(opts, :since, DateTime.add(DateTime.utc_now(), -24, :hour))
limit = Keyword.get(opts, :limit, 2880)
Check
|> where([c], c.device_id == ^device_id)
|> where([c], c.status == :success)
|> where([c], c.checked_at >= ^since)
|> where([c], not is_nil(c.response_time_ms))
|> order_by([c], desc: c.checked_at)
|> limit(^limit)
|> Repo.all()
end
end