Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
233 lines
7.5 KiB
Elixir
233 lines
7.5 KiB
Elixir
defmodule Towerops.ConfigChanges.Correlator do
|
|
@moduledoc """
|
|
Performance correlation engine for config change events.
|
|
|
|
Compares QoE metrics from Preseem in a window around a config change:
|
|
2 hours before and 4 hours after. Detects significant degradation and
|
|
creates insights when correlation is found.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.ConfigChanges.ConfigChangeEvent
|
|
alias Towerops.Preseem.AccessPoint
|
|
alias Towerops.Preseem.Insights
|
|
alias Towerops.Preseem.SubscriberMetric
|
|
alias Towerops.Repo
|
|
|
|
require Logger
|
|
|
|
@pre_window_hours 2
|
|
@post_window_hours 4
|
|
|
|
# Thresholds for "significant" impact
|
|
@latency_increase_threshold 0.20
|
|
@loss_increase_threshold 0.50
|
|
@throughput_drop_threshold 0.15
|
|
|
|
@doc """
|
|
Correlate a config change event with QoE metrics.
|
|
|
|
Returns `{:ok, :no_impact}`, `{:ok, :no_data}`, or `{:ok, insight}` when
|
|
significant degradation is detected.
|
|
"""
|
|
def correlate(%ConfigChangeEvent{} = event) do
|
|
# Find Preseem access points matched to this device
|
|
ap_ids = get_matched_ap_ids(event.device_id)
|
|
|
|
if Enum.empty?(ap_ids) do
|
|
{:ok, :no_data}
|
|
else
|
|
pre_start = DateTime.add(event.changed_at, -@pre_window_hours * 3600, :second)
|
|
post_end = DateTime.add(event.changed_at, @post_window_hours * 3600, :second)
|
|
|
|
pre_metrics = avg_metrics(ap_ids, pre_start, event.changed_at)
|
|
post_metrics = avg_metrics(ap_ids, event.changed_at, post_end)
|
|
|
|
case {pre_metrics, post_metrics} do
|
|
{nil, _} -> {:ok, :no_data}
|
|
{_, nil} -> {:ok, :no_data}
|
|
{pre, post} -> evaluate_impact(event, pre, post)
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Correlate all recent uncorrelated change events for an organization.
|
|
"""
|
|
def correlate_recent(organization_id, opts \\ []) do
|
|
hours_back = Keyword.get(opts, :hours_back, 24)
|
|
since = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
|
|
|
|
events =
|
|
ConfigChangeEvent
|
|
|> where(organization_id: ^organization_id)
|
|
|> where([e], e.changed_at >= ^since)
|
|
|> Repo.all()
|
|
|
|
results = Enum.map(events, &correlate/1)
|
|
{:ok, results}
|
|
end
|
|
|
|
# Find Preseem AP IDs matched to a device
|
|
defp get_matched_ap_ids(device_id) do
|
|
AccessPoint
|
|
|> where(device_id: ^device_id)
|
|
|> select([ap], ap.id)
|
|
|> Repo.all()
|
|
end
|
|
|
|
# Average metrics for given APs in a time window
|
|
defp avg_metrics(ap_ids, from, to) do
|
|
result =
|
|
SubscriberMetric
|
|
|> where([m], m.preseem_access_point_id in ^ap_ids)
|
|
|> where([m], m.recorded_at >= ^from and m.recorded_at < ^to)
|
|
|> select([m], %{
|
|
avg_latency: avg(m.avg_latency),
|
|
avg_jitter: avg(m.avg_jitter),
|
|
avg_loss: avg(m.avg_loss),
|
|
avg_throughput: avg(m.avg_throughput)
|
|
})
|
|
|> Repo.one()
|
|
|
|
if is_nil(result) || is_nil(result.avg_latency) do
|
|
nil
|
|
else
|
|
result
|
|
end
|
|
end
|
|
|
|
defp evaluate_impact(event, pre, post) do
|
|
deltas = %{
|
|
latency_delta: safe_pct_change(to_float(pre.avg_latency), to_float(post.avg_latency)),
|
|
jitter_delta: safe_pct_change(to_float(pre.avg_jitter), to_float(post.avg_jitter)),
|
|
loss_delta: safe_pct_change(to_float(pre.avg_loss), to_float(post.avg_loss)),
|
|
throughput_delta: safe_pct_change(to_float(pre.avg_throughput), to_float(post.avg_throughput))
|
|
}
|
|
|
|
significant? =
|
|
deltas.latency_delta > @latency_increase_threshold ||
|
|
deltas.loss_delta > @loss_increase_threshold ||
|
|
deltas.throughput_delta < -@throughput_drop_threshold
|
|
|
|
if significant? do
|
|
create_suspect_insight(event, deltas, pre, post)
|
|
else
|
|
{:ok, :no_impact}
|
|
end
|
|
end
|
|
|
|
defp create_suspect_insight(event, deltas, pre, post) do
|
|
severity = determine_severity(deltas)
|
|
|
|
attrs = %{
|
|
organization_id: event.organization_id,
|
|
device_id: event.device_id,
|
|
type: "suspect_config_change",
|
|
urgency: severity,
|
|
channel: "proactive",
|
|
source: "system",
|
|
title: "Config change may have degraded performance",
|
|
description: build_description(deltas, event),
|
|
metadata: %{
|
|
"change_event_id" => event.id,
|
|
"metric_deltas" => %{
|
|
"latency_pct" => Float.round(deltas.latency_delta * 100, 1),
|
|
"jitter_pct" => Float.round(deltas.jitter_delta * 100, 1),
|
|
"loss_pct" => Float.round(deltas.loss_delta * 100, 1),
|
|
"throughput_pct" => Float.round(deltas.throughput_delta * 100, 1)
|
|
},
|
|
"pre_metrics" => stringify_metrics(pre),
|
|
"post_metrics" => stringify_metrics(post),
|
|
"sections_changed" => event.sections_changed
|
|
}
|
|
}
|
|
|
|
Insights.insert_insight_if_new(attrs)
|
|
end
|
|
|
|
defp determine_severity(deltas) do
|
|
determine_severity_from_deltas(
|
|
deltas.latency_delta,
|
|
deltas.loss_delta,
|
|
deltas.throughput_delta
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Builds a human-readable description of a config change's impact.
|
|
|
|
Includes sections from `event.sections_changed` when present, and
|
|
includes metric deltas that exceed built-in reporting thresholds
|
|
(latency/loss > 10% increase, throughput > 5% drop).
|
|
"""
|
|
def build_description(deltas, event) do
|
|
parts =
|
|
[]
|
|
|> maybe_add(
|
|
"latency +#{round(deltas.latency_delta * 100)}%",
|
|
deltas.latency_delta > 0.10
|
|
)
|
|
|> maybe_add(
|
|
"loss +#{round(deltas.loss_delta * 100)}%",
|
|
deltas.loss_delta > 0.10
|
|
)
|
|
|> maybe_add(
|
|
"throughput #{round(deltas.throughput_delta * 100)}%",
|
|
deltas.throughput_delta < -0.05
|
|
)
|
|
|
|
"After config change#{format_sections(event)}: #{Enum.join(parts, ", ")}."
|
|
end
|
|
|
|
defp format_sections(%{sections_changed: sections}) when is_list(sections) and sections != [],
|
|
do: " (sections: #{Enum.join(sections, ", ")})"
|
|
|
|
defp format_sections(_event), do: ""
|
|
|
|
@doc """
|
|
Calculates percentage change between two values.
|
|
|
|
Returns `0.0` if the old value is zero or negative (avoids division by zero
|
|
and treats "no baseline" as "no change").
|
|
"""
|
|
@spec safe_pct_change(number(), number()) :: float()
|
|
def safe_pct_change(old, new_val) when old > 0.0, do: (new_val - old) / old
|
|
def safe_pct_change(_old, _new_val), do: 0.0
|
|
|
|
@doc """
|
|
Determines severity based on metric deltas expressed as ratios
|
|
(0.5 = 50% increase). Thresholds:
|
|
|
|
* `"critical"`: latency > 50% · loss > 100% · throughput < -30%
|
|
* `"warning"`: latency > 20% · loss > 50% · throughput < -15%
|
|
* `"info"`: otherwise
|
|
"""
|
|
@spec determine_severity_from_deltas(number(), number(), number()) :: String.t()
|
|
def determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta)
|
|
when latency_delta > 0.50 or loss_delta > 1.0 or throughput_delta < -0.30, do: "critical"
|
|
|
|
def determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta)
|
|
when latency_delta > 0.20 or loss_delta > 0.50 or throughput_delta < -0.15, do: "warning"
|
|
|
|
def determine_severity_from_deltas(_latency, _loss, _throughput), do: "info"
|
|
|
|
@doc false
|
|
def maybe_add(parts, text, true), do: parts ++ [text]
|
|
def maybe_add(parts, _text, false), do: parts
|
|
|
|
@doc false
|
|
def to_float(nil), do: 0.0
|
|
def to_float(v) when is_float(v), do: v
|
|
def to_float(v) when is_integer(v), do: v / 1
|
|
|
|
defp stringify_metrics(metrics) do
|
|
%{
|
|
"avg_latency" => metrics.avg_latency && Float.round(metrics.avg_latency, 2),
|
|
"avg_jitter" => metrics.avg_jitter && Float.round(metrics.avg_jitter, 2),
|
|
"avg_loss" => metrics.avg_loss && Float.round(metrics.avg_loss, 4),
|
|
"avg_throughput" => metrics.avg_throughput && Float.round(metrics.avg_throughput, 2)
|
|
}
|
|
end
|
|
end
|