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 | {:unrecognized, String.t()} @type t :: %__MODULE__{ type: String.t(), target: String.t() | nil, operator: operator(), value: term(), severity: :down | :degraded | {:unrecognized, String.t()} } @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, {:unrecognized, op}) 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" -> :down _ -> {:unrecognized, sev} end end @doc """ Evaluates a single assertion against the results map. """ @spec evaluate(t(), map()) :: outcome() def evaluate(%__MODULE__{} = assertion, results) do case validate_config(assertion) do {:error, reason} -> {:fail, :down, reason} :ok -> 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 end # A typo'd operator/severity string used to silently fall back to :eq/:down # (or, worse, to whatever unrelated atom happened to already exist in the # VM) — indistinguishable from an intentionally-lenient config. Surfacing # it as a hard failure means a misconfigured assertion is visibly broken # instead of quietly checking the wrong thing (or nothing at all). defp validate_config(%__MODULE__{operator: {:unrecognized, raw}} = assertion) do {:error, "Assertion #{assertion.type}: unrecognized operator #{inspect(raw)}"} end defp validate_config(%__MODULE__{severity: {:unrecognized, raw}} = assertion) do {:error, "Assertion #{assertion.type}: unrecognized severity #{inspect(raw)}"} end defp validate_config(_assertion), do: :ok @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: values_equal?(actual, value) defp compare(actual, :ne, value), do: not values_equal?(actual, value) defp compare(actual, :lt, value), do: numeric_compare(actual, value, &Kernel./2) defp compare(actual, :gte, value), do: numeric_compare(actual, value, &Kernel.>=/2) 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 # Numeric on both sides (e.g. actual `200`, configured `"200"`) compares # numerically so a stringified config value doesn't produce a spurious # mismatch against a genuinely numeric result; otherwise falls back to # string/atom comparison exactly as before. defp values_equal?(a, b) do case {to_number(a), to_number(b)} do {{:ok, na}, {:ok, nb}} -> na == nb _ -> to_string_or_number(a) == to_string_or_number(b) end end # Either side failing to parse as a number used to silently coerce to `0` # — a typo'd threshold like `value: "fst"` became `latency_ms > 0`, true # for virtually any real result, so the assertion always "passed" instead # of flagging the bad config. Now it just fails the comparison instead. defp numeric_compare(actual, value, comparator) do case {to_number(actual), to_number(value)} do {{:ok, a}, {:ok, v}} -> comparator.(a, v) _ -> false end end 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) @spec to_number(term()) :: {:ok, number()} | :error defp to_number(val) when is_number(val), do: {:ok, val} defp to_number(val) when is_binary(val) do case Float.parse(val) do {num, ""} -> {:ok, num} _ -> :error end end defp to_number(_), do: :error 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