defmodule CodeNameRaven.Monitor.Assertion do @moduledoc """ A generic assertion framework for monitoring integrations. Allows operators to define configurable health check thresholds. """ defstruct [:type, :target, :operator, :value, severity: :down] @type operator :: :eq | :ne | :lt | :lte | :gt | :gte | :contains | :not_contains | :matches | :in | :present @type t :: %__MODULE__{ type: String.t(), target: String.t() | nil, operator: operator(), value: term(), severity: :down | :degraded } @type outcome :: :pass | {:fail, :down | :degraded, String.t()} @operators %{ "eq" => :eq, "ne" => :ne, "lt" => :lt, "lte" => :lte, "gt" => :gt, "gte" => :gte, "contains" => :contains, "not_contains" => :not_contains, "matches" => :matches, "in" => :in, "present" => :present } @doc """ Parses a list of assertion maps (as received from params) into a list of Assertion structs. """ @spec parse([map()]) :: [t()] def parse(list) when is_list(list) do Enum.map(list, &parse_one/1) end def parse(_), do: [] defp parse_one(map) when is_map(map) do %__MODULE__{ type: map[:type] || map["type"], target: map[:target] || map["target"], operator: to_operator(map[:operator] || map["operator"]), value: map[:value] || map["value"], severity: to_severity(map[:severity] || map["severity"]) } end defp to_operator(nil), do: :eq defp to_operator(op) when is_atom(op), do: op defp to_operator(op) when is_binary(op) do op_lower = String.downcase(op) Map.get(@operators, op_lower) || try_to_existing_atom(op_lower) || :eq end defp to_severity(nil), do: :down defp to_severity(sev) when is_atom(sev), do: sev defp to_severity(sev) when is_binary(sev) do case String.downcase(sev) do "degraded" -> :degraded _ -> :down end end @doc """ Evaluates a single assertion against the results map. """ @spec evaluate(t(), map()) :: outcome() def evaluate(%__MODULE__{} = assertion, results) do case get_actual_value(assertion, results) do {:ok, actual} -> if compare(actual, assertion.operator, assertion.value) do :pass else {:fail, assertion.severity, format_failure(assertion, actual)} end {:error, reason} -> {:fail, assertion.severity, reason} end end @doc """ Evaluates all assertions against the results map, returning a combined status and a list of failure reason messages. """ @spec evaluate_all([t()], map()) :: {:up | :degraded | :down, [String.t()]} def evaluate_all(assertions, results) do if assertions == [] do {:up, []} else outcomes = Enum.map(assertions, &evaluate(&1, results)) failures = Enum.flat_map(outcomes, fn {:fail, _severity, reason} -> [reason] _ -> [] end) status = cond do Enum.any?(outcomes, fn {:fail, :down, _} -> true _ -> false end) -> :down Enum.any?(outcomes, fn {:fail, :degraded, _} -> true _ -> false end) -> :degraded true -> :up end {status, failures} end end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp get_actual_value(%__MODULE__{type: type}, results) when type in ["status_code", "status_code_in"] do val = results[:status_code] || results[:status] if is_nil(val), do: {:error, "Status code not found in results"}, else: {:ok, val} end defp get_actual_value(%__MODULE__{type: type}, results) when type in ["body_contains", "body_matches"] do {:ok, results[:body] || ""} end defp get_actual_value(%__MODULE__{type: type, target: target}, results) when type in ["header_eq", "header_present"] do headers = results[:headers] || [] found = find_header(headers, target) if type == "header_eq" do case found do {:ok, val} -> {:ok, val} :error -> {:error, "Header #{inspect(target)} not found"} end else case found do {:ok, _} -> {:ok, true} :error -> {:ok, false} end end end defp get_actual_value(%__MODULE__{type: "cert_expiry_days"}, results) do val = results[:cert_expiry_days] if is_nil(val), do: {:error, "Certificate expiry days not found/SSL not used"}, else: {:ok, val} end defp get_actual_value(%__MODULE__{type: "latency_ms", target: target}, results) do get_latency_ms(target, results) end defp get_actual_value(%__MODULE__{type: "redirect_count"}, results) do {:ok, results[:redirects] || 0} end defp get_actual_value(assertion, results) do key = assertion.type atom_key = try_to_existing_atom(key) cond do Map.has_key?(results, key) -> {:ok, Map.get(results, key)} atom_key && Map.has_key?(results, atom_key) -> {:ok, Map.get(results, atom_key)} val = find_nested_metric(results, key, atom_key) -> {:ok, val} true -> {:error, "Metric key #{inspect(key)} not found in results"} end end defp get_latency_ms(nil, results), do: get_total_latency(results) defp get_latency_ms("latency_ms", results), do: get_total_latency(results) defp get_latency_ms("total_ms", results), do: get_total_latency(results) defp get_latency_ms(target, results) do timing = results[:timing] || %{} atom_key = try_to_existing_atom(target) val = timing[target] || (atom_key && timing[atom_key]) if is_nil(val), do: {:error, "Timing key #{target} not found"}, else: {:ok, val} end defp get_total_latency(results) do val = results[:latency_ms] || results[:total_ms] if is_nil(val), do: {:error, "Latency not found"}, else: {:ok, val} end defp find_nested_metric(results, key, atom_key) do Enum.find_value(Map.values(results), fn nested_map when is_map(nested_map) -> cond do Map.has_key?(nested_map, key) -> Map.get(nested_map, key) atom_key && Map.has_key?(nested_map, atom_key) -> Map.get(nested_map, atom_key) true -> nil end _ -> nil end) end defp compare(actual, :eq, value), do: to_string_or_number(actual) == to_string_or_number(value) defp compare(actual, :ne, value), do: to_string_or_number(actual) != to_string_or_number(value) defp compare(actual, :lt, value), do: to_number(actual) < to_number(value) defp compare(actual, :lte, value), do: to_number(actual) <= to_number(value) defp compare(actual, :gt, value), do: to_number(actual) > to_number(value) defp compare(actual, :gte, value), do: to_number(actual) >= to_number(value) defp compare(actual, :contains, value) do if is_binary(actual) and is_binary(value) do String.contains?(actual, value) else false end end defp compare(actual, :not_contains, value) do if is_binary(actual) and is_binary(value) do not String.contains?(actual, value) else true end end defp compare(actual, :matches, value) do if is_binary(actual) and is_binary(value) do case Regex.compile(value) do {:ok, regex} -> Regex.match?(regex, actual) _ -> false end else false end end defp compare(actual, :in, value) when is_list(value) do Enum.any?(value, &compare(actual, :eq, &1)) end defp compare(actual, :present, value) do # If checking header presence, actual will be a boolean (found or not found) # value can be true or false actual == !!value end defp compare(_, _, _), do: false defp find_header(headers, target) when is_list(headers) and is_binary(target) do target_lower = String.downcase(target) case Enum.find(headers, fn {k, _v} -> String.downcase(to_string(k)) == target_lower end) do {_k, v} -> {:ok, to_string(v)} nil -> :error end end defp find_header(_, _), do: :error defp to_string_or_number(val) when is_binary(val), do: val defp to_string_or_number(val) when is_number(val), do: val defp to_string_or_number(val) when is_atom(val), do: to_string(val) defp to_string_or_number(val), do: inspect(val) defp to_number(val) when is_number(val), do: val defp to_number(val) when is_binary(val) do case Float.parse(val) do {num, _} -> num :error -> 0 end end defp to_number(_), do: 0 defp try_to_existing_atom(nil), do: nil defp try_to_existing_atom(str) when is_binary(str) do String.to_existing_atom(str) rescue _ -> nil end defp try_to_existing_atom(atom) when is_atom(atom), do: atom defp format_failure(assertion, actual) do target_part = if assertion.target, do: " [#{assertion.target}]", else: "" "Assertion #{assertion.type}#{target_part} failed: expected #{assertion.operator} #{inspect(assertion.value)}, but got #{inspect(actual)}" end end