Packages
ICMP ping monitor, with its dashboard panel bundled in the same package as a separate module (Integrations.Ping.Display, #299) — one install, both halves; a release without raven_web simply runs the monitor headless.
Current section
Files
Jump to
Current section
Files
lib/integrations/ping.ex
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