defmodule Integrations.Ping do @moduledoc """ ICMP ping monitor. Sends a configurable number of ICMP echo requests and reports packet loss and round-trip time. Uses the system `ping` binary (which carries the setuid bit allowing unprivileged use) rather than raw sockets, which would require `CAP_NET_RAW` inside a container. Collection only — see `Integrations.Ping.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts, same as a single-module package would; a release without `raven_web` simply never compiles the display half and runs this monitor headless. ## Params * `:host` — Hostname or IP address to ping. Required. * `:count` — Number of packets to send. Defaults to `4`. * `:timeout_ms` — Wait timeout per packet in milliseconds. Defaults to `1000`. * `:packet_loss_degraded_pct`— Packet loss percentage that triggers `:degraded`. Defaults to `25`. * `:packet_loss_down_pct` — Packet loss percentage that triggers `:down`. Defaults to `100`. * `:latency_degraded_ms` — Average RTT that triggers `:degraded`. Defaults to `200`. ## Health signal * `:up` — All packets received; RTT within threshold. * `:degraded` — Partial loss or RTT above `:latency_degraded_ms`. * `:down` — All packets lost or host unreachable. """ use CodeNameRaven.Monitor @default_count 4 @default_timeout_ms 1_000 @default_degraded_loss_pct 25 @default_down_loss_pct 100 @default_latency_degraded_ms 200 @impl true def params_template do %{host: "", count: "4", timeout_ms: "1000"} end @impl true def params_schema do [ host: [type: :string, required: true, doc: "Hostname or IP address to ping"], count: [type: :non_neg_integer, default: 4, doc: "Number of ICMP packets to send"], timeout_ms: [type: :non_neg_integer, default: 1_000, doc: "Per-packet timeout in milliseconds"], packet_loss_degraded_pct: [type: :non_neg_integer, default: 25, doc: "Packet loss % threshold for degraded"], packet_loss_down_pct: [type: :non_neg_integer, default: 100, doc: "Packet loss % threshold for down"], latency_degraded_ms: [type: :non_neg_integer, default: 200, doc: "Average RTT threshold for degraded"] ] end @impl true def target_uri(params) do case get_param(params, :host) do nil -> :none host -> {:ok, "icmp://#{host}"} end end @impl true def identity_params(params), do: %{host: get_param(params, :host)} # --------------------------------------------------------------------------- # Collect # --------------------------------------------------------------------------- @impl true def collect(params, state) do host = get_param(params, :host) if is_nil(host) do {:error, "missing required param :host", state} else count = parse_int(get_param(params, :count), @default_count) timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms) timeout_s = max(1, div(timeout_ms, 1_000)) case :os.find_executable('ping') do false -> {:error, "ping binary not found; install iputils-ping on the host system", state} _ -> {os_type, _} = :os.type() args = build_args(os_type, host, count, timeout_s) run_ping(args, os_type, state) end end end defp run_ping(args, os_type, state) do case System.cmd("ping", args, stderr_to_stdout: true) do {output, 0} -> {:ok, parse_output(output, os_type), state} {output, _exit_code} -> result = parse_output(output, os_type) if result.packets_transmitted > 0 do {:ok, result, state} else {:error, String.trim(output), state} end end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(result) do degraded_pct = result[:loss_degraded_pct] || @default_degraded_loss_pct down_pct = result[:loss_down_pct] || @default_down_loss_pct lat_deg_ms = result[:latency_degraded_ms] || @default_latency_degraded_ms loss = result.packet_loss_pct cond do loss >= down_pct -> :down loss >= degraded_pct -> :degraded is_number(result.avg_rtt_ms) and result.avg_rtt_ms >= lat_deg_ms -> :degraded true -> :up end end # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do %{ packet_loss_pct: result.packet_loss_pct, packets_transmitted: result.packets_transmitted, packets_received: result.packets_received } |> maybe_put(:avg_rtt_ms, result.avg_rtt_ms) |> maybe_put(:min_rtt_ms, result.min_rtt_ms) |> maybe_put(:max_rtt_ms, result.max_rtt_ms) end # --------------------------------------------------------------------------- # Platform-specific arg builders # --------------------------------------------------------------------------- # Linux: -c count -W timeout_per_packet_seconds defp build_args(:unix, host, count, timeout_s), do: ["-c", to_string(count), "-W", to_string(timeout_s), host] # macOS: -c count -W timeout_ms (milliseconds on macOS) defp build_args(:win32, host, count, _timeout_s), do: ["-n", to_string(count), host] # Fallback (BSD / macOS share the :unix os_type; detect via uname if needed) defp build_args(_, host, count, timeout_s), do: ["-c", to_string(count), "-W", to_string(timeout_s), host] # --------------------------------------------------------------------------- # Output parsers # --------------------------------------------------------------------------- defp parse_output(output, _os_type) do {transmitted, received, loss_pct} = parse_packet_stats(output) {min_rtt, avg_rtt, max_rtt} = parse_rtt_stats(output) %{ packets_transmitted: transmitted, packets_received: received, packet_loss_pct: loss_pct, min_rtt_ms: min_rtt, avg_rtt_ms: avg_rtt, max_rtt_ms: max_rtt } end # iputils: "4 packets transmitted, 4 received, 0% packet loss" # BusyBox: "4 packets transmitted, 4 packets received, 0% packet loss" # (the extra "packets" before "received" — everything else # identical — is why this used to fall through to the 0/0/100 # catch-all below on every BusyBox-based container image, # reporting false 100% loss regardless of real connectivity) defp parse_packet_stats(output) do with [[_, tx, rx, loss]] <- Regex.scan(~r/(\d+) packets transmitted.*?(\d+)(?: packets)? received.*?(\d+)% packet loss/, output) do {String.to_integer(tx), String.to_integer(rx), String.to_integer(loss)} else _ -> # Windows: "Sent = 4, Received = 4, Lost = 0 (0% loss)" with [[_, tx, rx, loss]] <- Regex.scan(~r/Sent\s*=\s*(\d+),\s*Received\s*=\s*(\d+),\s*Lost\s*=\s*\d+\s*\((\d+)%/, output) do {String.to_integer(tx), String.to_integer(rx), String.to_integer(loss)} else _ -> {0, 0, 100} end end end # "rtt min/avg/max/mdev = 0.123/0.456/0.789/0.100 ms" defp parse_rtt_stats(output) do with [[_, min, avg, max, _]] <- Regex.scan(~r|rtt min/avg/max/\w+ = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+) ms|, output) do { parse_float(min), parse_float(avg), parse_float(max) } else # macOS: "round-trip min/avg/max/stddev = 0.123/0.456/0.789/0.100 ms" _ -> with [[_, min, avg, max, _]] <- Regex.scan(~r|round-trip min/avg/max/\w+ = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+) ms|, output) do {parse_float(min), parse_float(avg), parse_float(max)} else # BusyBox: "round-trip min/avg/max = 0.123/0.456/0.789 ms" — only # three values, no mdev/stddev column at all (not just a # differently-named one, like the macOS case above). _ -> with [[_, min, avg, max]] <- Regex.scan(~r|round-trip min/avg/max = ([\d.]+)/([\d.]+)/([\d.]+) ms|, output) do {parse_float(min), parse_float(avg), parse_float(max)} else # Windows: "Minimum = 1ms, Maximum = 2ms, Average = 1ms" _ -> with [[_, min]] <- Regex.scan(~r/Minimum\s*=\s*(\d+)ms/, output), [[_, avg]] <- Regex.scan(~r/Average\s*=\s*(\d+)ms/, output), [[_, max]] <- Regex.scan(~r/Maximum\s*=\s*(\d+)ms/, output) do {parse_float(min), parse_float(avg), parse_float(max)} else _ -> {nil, nil, nil} end end end end end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp get_param(params, key) when is_atom(key) do v = params[key] || params[to_string(key)] if is_binary(v) and String.trim(v) == "", do: nil, else: v end defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, val), do: Map.put(map, key, val) defp parse_int(nil, default), do: default defp parse_int(v, _) when is_integer(v), do: v defp parse_int(v, default) when is_binary(v) do case Integer.parse(v) do {n, _} -> n :error -> default end end defp parse_int(_, default), do: default defp parse_float(s) when is_binary(s) do case Float.parse(s) do {f, _} -> Float.round(f, 3) :error -> nil end end defp parse_float(_), do: nil end