towerops/lib/towerops/config_changes/correlator.ex
Graham McIntire efaf5558ff refactor: convert 6 Gleam modules to idiomatic Elixir with TDD (#196)
Phase 1: Foundation Types (100% complete)
- query_helpers: SQL LIKE sanitization with pipe operators
- numeric: Integer parsing with pattern matching guards
- result: Pure Elixir Result monad (map, and_then, unwrap_or)

Phase 2: Ecto Domain Types (100% complete)
- ip_address: IPv4/IPv6 validation using :inet directly
- mac_address: Multi-format MAC parsing (colon/hyphen/dot/compact)
- snmp_oid: OID parsing/manipulation with recursive pattern matching

All 198 tests passing across converted modules.
API changed from Gleam-style {:some/:none to idiomatic {:ok/:error.
Refactored parse_numeric_oid to use with statement, reducing nesting depth.

Reviewed-on: graham/towerops-web#196
2026-03-28 09:52:07 -05:00

215 lines
6.8 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
defp 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
)
sections =
if Enum.any?(event.sections_changed),
do: " (sections: #{Enum.join(event.sections_changed, ", ")})",
else: ""
"After config change#{sections}: #{Enum.join(parts, ", ")}."
end
# Calculate percentage change between two values.
# Returns 0.0 if old value is zero or negative.
defp safe_pct_change(old, new_val) when old > 0.0, do: (new_val - old) / old
defp safe_pct_change(_old, _new_val), do: 0.0
# Determine severity based on metric deltas.
# - "critical" when latency > 50%, loss > 100%, or throughput < -30%
# - "warning" when latency > 20%, loss > 50%, or throughput < -15%
# - "info" otherwise
defp determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta) do
cond do
latency_delta > 0.50 or loss_delta > 1.0 or throughput_delta < -0.30 -> "critical"
latency_delta > 0.20 or loss_delta > 0.50 or throughput_delta < -0.15 -> "warning"
true -> "info"
end
end
# Append text to list if condition is true, otherwise return list unchanged.
defp maybe_add(parts, text, true), do: parts ++ [text]
defp maybe_add(parts, _text, false), do: parts
defp to_float(nil), do: 0.0
defp to_float(v) when is_float(v), do: v
defp 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