Packages

XMPP server monitor, with its dashboard panel bundled in the same package as a separate module (Integrations.Xmpp.Display) — one install, both halves; a release without raven_web simply runs the monitor headless.

Current section

Files

Jump to
raven_integration_xmpp lib integrations xmpp.ex
Raw

lib/integrations/xmpp.ex

defmodule Integrations.Xmpp do
@moduledoc """
XMPP server monitor.
Opens a TCP connection to an XMPP server and sends an XML stream opening.
A healthy server responds with its own `<stream:stream ...>` header and
advertises its supported features. The check confirms the server is alive
and speaking the XMPP protocol without performing authentication.
Uses raw `:gen_tcp` — no external dependencies required.
Collection only — see `Integrations.Xmpp.Display` (same package) for the
dashboard panel. `Display.BundledDefault` auto-hooks it whenever this
monitor starts.
## Params
* `:host` — XMPP server hostname or IP. Required.
* `:port` — Port number. Defaults to `5222` (client-to-server).
Use `5269` for server-to-server federation checks.
* `:domain` — XMPP domain to use in the stream `to` attribute.
Defaults to `:host`. Set this when the domain differs
from the server address (virtual hosting).
* `:timeout_ms` — TCP connect and receive timeout in milliseconds.
Defaults to `5000`.
* `:latency_degraded_ms` — Time-to-first-byte threshold for `:degraded`.
Defaults to `1000`.
## Health signal
* `:up` — Server responded with a valid XMPP stream header and
latency is within threshold.
* `:degraded` — Server responded but latency exceeded threshold, or
the response was not a well-formed stream header.
* `:down` — Connection refused, timeout, or no response.
"""
use CodeNameRaven.Monitor
@default_port 5222
@default_timeout_ms 5_000
@default_lat_degraded_ms 1_000
@stream_ns "jabber:client"
@impl true
def params_template do
%{host: "", port: "5222", domain: "", timeout_ms: "5000"}
end
@impl true
def params_schema do
[
host: [type: :string, required: true, doc: "XMPP server hostname or IP"],
port: [type: :non_neg_integer, default: 5222, doc: "Port (5222 client, 5269 server federation)"],
domain: [type: :string, required: false, doc: "XMPP domain for stream header (defaults to host)"],
timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "TCP connect and receive timeout in milliseconds"],
latency_degraded_ms: [type: :non_neg_integer, default: 1_000, doc: "TTFB threshold for degraded"]
]
end
@impl true
def target_uri(params) do
host = get_param(params, :host)
port = parse_int(get_param(params, :port), @default_port)
if host, do: {:ok, "xmpp://#{host}:#{port}"}, else: :none
end
@impl true
def identity_params(params) do
%{
host: get_param(params, :host),
port: parse_int(get_param(params, :port), @default_port)
}
end
# ---------------------------------------------------------------------------
# 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
port = parse_int(get_param(params, :port), @default_port)
timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms)
domain = get_param(params, :domain) || host
tcp_opts = [:binary, packet: :raw, active: false, send_timeout: timeout_ms]
started_at = System.monotonic_time(:millisecond)
case :gen_tcp.connect(to_charlist(host), port, tcp_opts, timeout_ms) do
{:ok, socket} ->
result = do_xmpp_check(socket, domain, timeout_ms, started_at)
:gen_tcp.close(socket)
case result do
{:ok, data} -> {:ok, data, state}
{:error, reason} -> {:error, reason, state}
end
{:error, :econnrefused} ->
{:error, "connection refused on port #{port}", state}
{:error, :timeout} ->
{:error, "connection timed out", state}
{:error, :nxdomain} ->
{:error, "hostname not found: #{host}", state}
{:error, reason} ->
{:error, inspect(reason), state}
end
end
end
# ---------------------------------------------------------------------------
# Healthy?
# ---------------------------------------------------------------------------
@impl true
def healthy?(result) do
lat_deg = result[:latency_degraded_ms] || @default_lat_degraded_ms
cond do
not result.stream_ok -> :degraded
result.latency_ms >= lat_deg -> :degraded
true -> :up
end
end
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@impl true
def metrics(result) do
%{latency_ms: result.latency_ms}
end
# ---------------------------------------------------------------------------
# XMPP protocol
# ---------------------------------------------------------------------------
defp do_xmpp_check(socket, domain, timeout_ms, started_at) do
stream_open = "<stream:stream xmlns='#{@stream_ns}' xmlns:stream='http://etherx.jabber.org/streams' to='#{domain}' version='1.0'>"
:gen_tcp.send(socket, stream_open)
case recv_until_stream_header(socket, timeout_ms, "") do
{:ok, data} ->
ttfb_ms = System.monotonic_time(:millisecond) - started_at
stream_ok = String.contains?(data, "<stream:stream") or
String.contains?(data, "<stream:error")
server_id = extract_attribute(data, "id")
from = extract_attribute(data, "from")
features = extract_features(data)
{:ok, %{
latency_ms: ttfb_ms,
stream_ok: stream_ok,
server_id: server_id || from,
features: features
}}
{:error, reason} ->
{:error, reason}
end
end
# Read until we have seen the stream:stream opening tag close
# (it's not a complete XML doc, so we just collect until we see '>')
defp recv_until_stream_header(socket, timeout_ms, acc) do
case :gen_tcp.recv(socket, 0, timeout_ms) do
{:ok, data} ->
acc = acc <> data
# Stream header is complete once we see the closing '>' of the opening tag
# We also accept partial data that has the stream:stream element
if String.contains?(acc, "<stream:stream") and String.contains?(acc, ">") do
{:ok, acc}
else
recv_until_stream_header(socket, timeout_ms, acc)
end
{:error, :timeout} -> {:error, "timed out waiting for stream header"}
{:error, reason} -> {:error, inspect(reason)}
end
end
defp extract_attribute(xml, attr) do
case Regex.run(~r/\b#{attr}="([^"]*)"/, xml) do
[_, value] -> value
_ -> nil
end
end
defp extract_features(xml) do
# Extract feature names from <stream:features>...</stream:features>
case Regex.run(~r|<stream:features>(.*?)</stream:features>|s, xml) do
[_, features_xml] ->
Regex.scan(~r/<(\w+[:\w]*)[^>]*\/>|<(\w+[:\w]*)[^>]*>/, features_xml)
|> Enum.flat_map(fn
[_, feat, ""] -> [feat]
[_, "", feat] -> [feat]
_ -> []
end)
|> Enum.reject(&(&1 in ["stream:features", ""]))
|> Enum.uniq()
_ -> []
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 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
end