260 lines
8.6 KiB
Elixir
260 lines
8.6 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.
|
|
"""
|
|
@spec create_check(map()) :: {:ok, Check.t()} | {:error, Ecto.Changeset.t()}
|
|
def create_check(attrs) do
|
|
%Check{}
|
|
|> Check.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Returns the list of checks for an device.
|
|
"""
|
|
@spec list_devices_checks(String.t(), integer()) :: [Check.t()]
|
|
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.
|
|
"""
|
|
@spec get_latest_check(String.t()) :: Check.t() | nil
|
|
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.
|
|
"""
|
|
@spec delete_old_checks(DateTime.t()) :: {integer(), nil | [term()]}
|
|
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.
|
|
"""
|
|
@spec get_hourly_stats(String.t(), DateTime.t(), DateTime.t()) :: {:ok, Postgrex.Result.t()} | {:error, Exception.t()}
|
|
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
|