Packages
Dragonfly in-memory store monitor, with its dashboard panel bundled in the same package as a separate module (Integrations.Dragonfly.Display) — one install, both halves; a release without raven_web simply runs the monitor headless.
Current section
Files
Jump to
Current section
Files
lib/integrations/dragonfly.ex
defmodule Integrations.Dragonfly do
@moduledoc """
Dragonfly in-memory store monitor.
Dragonfly is a Redis-compatible in-memory data store. This monitor connects
via the RESP protocol over raw TCP, verifies the server is alive with a PING,
and optionally fetches `INFO` statistics for memory, keyspace hit rate,
connected clients, ops throughput, and eviction activity.
Collection only — see `Integrations.Dragonfly.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` — Dragonfly hostname or IP. Required.
* `:port` — Port number. Defaults to `6379`.
* `:password` — AUTH password (optional). Leave blank for no auth.
* `:timeout_ms` — TCP connect and receive timeout. Defaults to `5000`.
* `:collect_info` — Fetch INFO statistics. Defaults to `true`.
* `:latency_degraded_ms` — Latency threshold for `:degraded`. Defaults to `200`.
## Health signal
* `:up` — PING received +PONG; latency within threshold.
* `:degraded` — Server responded but latency exceeded threshold.
* `:down` — Connection refused, timeout, AUTH failure, or unexpected response.
## Metrics
* `latency_ms` — Round-trip time for PING
* `used_memory_bytes` — RSS memory in use
* `maxmemory_bytes` — Configured memory limit (0 = unlimited)
* `connected_clients` — Current client count
* `hit_rate_pct` — Keyspace hit rate (hits / (hits + misses) * 100)
* `ops_per_sec` — Instantaneous operations per second
* `evicted_keys` — Total keys evicted due to memory pressure
* `expired_keys` — Total keys expired by TTL
* `replication_lag` — `master_repl_offset - slave_repl_offset` for replicas
"""
use CodeNameRaven.Monitor
@default_port 6379
@default_timeout_ms 5_000
@default_lat_degraded_ms 200
@impl true
def params_template do
%{host: "dragonfly.example.com", port: "6379", password: "", timeout_ms: "5000"}
end
@impl true
def params_schema do
[
host: [type: :string, required: true, doc: "Dragonfly hostname or IP"],
port: [type: :non_neg_integer, default: 6379, doc: "Port number"],
password: [type: :string, required: false, doc: "AUTH password (blank = no auth)"],
timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "TCP connect and receive timeout in milliseconds"],
collect_info: [type: :boolean, default: true, doc: "Fetch INFO statistics"],
latency_degraded_ms: [type: :non_neg_integer, default: 200, doc: "Latency 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, "redis://#{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)
password = get_param(params, :password)
collect_info = truthy?(get_param(params, :collect_info), default: true)
tcp_opts = [:binary, active: false, packet: :raw]
case :gen_tcp.connect(to_charlist(host), port, tcp_opts, timeout_ms) do
{:ok, socket} ->
result = do_check(socket, password, collect_info, timeout_ms)
: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 after #{timeout_ms}ms", 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
if result.latency_ms >= lat_deg, do: :degraded, else: :up
end
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@impl true
def metrics(result) do
%{latency_ms: result.latency_ms}
|> maybe_put(:used_memory_bytes, result[:used_memory_bytes])
|> maybe_put(:maxmemory_bytes, result[:maxmemory_bytes])
|> maybe_put(:connected_clients, result[:connected_clients])
|> maybe_put(:hit_rate_pct, result[:hit_rate_pct])
|> maybe_put(:ops_per_sec, result[:ops_per_sec])
|> maybe_put(:evicted_keys, result[:evicted_keys])
|> maybe_put(:expired_keys, result[:expired_keys])
|> maybe_put(:replication_lag, result[:replication_lag])
end
# ---------------------------------------------------------------------------
# RESP protocol
# ---------------------------------------------------------------------------
defp do_check(socket, password, collect_info, timeout_ms) do
with :ok <- maybe_auth(socket, password, timeout_ms) do
t0 = System.monotonic_time(:millisecond)
:gen_tcp.send(socket, "*1\r\n$4\r\nPING\r\n")
case recv_line(socket, timeout_ms) do
{:ok, "+PONG"} ->
latency_ms = System.monotonic_time(:millisecond) - t0
base = %{latency_ms: latency_ms}
if collect_info do
case fetch_info(socket, timeout_ms) do
{:ok, info} -> {:ok, Map.merge(base, info)}
_ -> {:ok, base}
end
else
{:ok, base}
end
{:ok, other} ->
{:error, "unexpected PING response: #{inspect(other)}"}
{:error, reason} ->
{:error, reason}
end
end
end
defp maybe_auth(_socket, nil, _timeout_ms), do: :ok
defp maybe_auth(_socket, "", _timeout_ms), do: :ok
defp maybe_auth(socket, password, timeout_ms) do
cmd = "*2\r\n$4\r\nAUTH\r\n$#{byte_size(password)}\r\n#{password}\r\n"
:gen_tcp.send(socket, cmd)
case recv_line(socket, timeout_ms) do
{:ok, "+OK"} -> :ok
{:ok, "-" <> err} -> {:error, "AUTH failed: #{String.trim(err)}"}
{:ok, other} -> {:error, "AUTH unexpected: #{inspect(other)}"}
{:error, reason} -> {:error, reason}
end
end
defp fetch_info(socket, timeout_ms) do
:gen_tcp.send(socket, "*1\r\n$4\r\nINFO\r\n")
case recv_bulk_string(socket, timeout_ms) do
{:ok, body} -> {:ok, parse_info(body)}
_ -> :error
end
end
defp recv_bulk_string(socket, timeout_ms) do
case recv_line(socket, timeout_ms) do
{:ok, "$" <> size_str} ->
size = String.to_integer(String.trim(size_str))
case :gen_tcp.recv(socket, size + 2, timeout_ms) do
{:ok, data} -> {:ok, binary_part(data, 0, size)}
{:error, r} -> {:error, inspect(r)}
end
{:ok, other} ->
{:error, "expected bulk string, got: #{inspect(other)}"}
{:error, reason} ->
{:error, reason}
end
end
defp recv_line(socket, timeout_ms), do: recv_line_acc(socket, timeout_ms, "")
defp recv_line_acc(socket, timeout_ms, acc) do
case :gen_tcp.recv(socket, 1, timeout_ms) do
{:ok, "\n"} -> {:ok, String.trim_trailing(acc, "\r")}
{:ok, byte} -> recv_line_acc(socket, timeout_ms, acc <> byte)
{:error, r} -> {:error, "recv failed: #{inspect(r)}"}
end
end
defp parse_info(body) do
fields =
body
|> String.split("\r\n")
|> Enum.reject(&(String.starts_with?(&1, "#") or &1 == ""))
|> Map.new(fn line ->
case String.split(line, ":", parts: 2) do
[k, v] -> {k, String.trim(v)}
_ -> {"", ""}
end
end)
%{}
|> put_string(fields, "dragonfly_version", :version)
|> put_integer(fields, "used_memory_rss", :used_memory_bytes)
|> put_integer(fields, "maxmemory", :maxmemory_bytes)
|> put_integer(fields, "connected_clients", :connected_clients)
|> put_integer(fields, "instantaneous_ops_per_sec", :ops_per_sec)
|> put_integer(fields, "evicted_keys", :evicted_keys)
|> put_integer(fields, "expired_keys", :expired_keys)
|> put_hit_rate(fields)
|> put_replication_lag(fields)
end
defp put_string(result, fields, key, atom) do
case Map.get(fields, key) do
nil -> result
v -> Map.put(result, atom, v)
end
end
defp put_integer(result, fields, key, atom) do
case Integer.parse(fields[key] || "") do
{n, _} -> Map.put(result, atom, n)
_ -> result
end
end
defp put_hit_rate(result, fields) do
with {hits, _} <- Integer.parse(fields["keyspace_hits"] || ""),
{misses, _} <- Integer.parse(fields["keyspace_misses"] || ""),
total = hits + misses,
true <- total > 0 do
Map.put(result, :hit_rate_pct, round(hits / total * 100))
else
_ -> result
end
end
defp put_replication_lag(result, fields) do
with {master, _} <- Integer.parse(fields["master_repl_offset"] || ""),
{slave, _} <- Integer.parse(fields["slave_repl_offset"] || "") do
Map.put(result, :replication_lag, master - slave)
else
_ -> result
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, val), do: Map.put(map, key, val)
defp truthy?(nil, opts), do: Keyword.get(opts, :default, false)
defp truthy?("true", _), do: true
defp truthy?(true, _), do: true
defp truthy?("false", _), do: false
defp truthy?(false, _), do: false
defp truthy?(_, opts), do: Keyword.get(opts, :default, false)
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