Packages
OpenSearch (and Elasticsearch-compatible) cluster monitor, with its dashboard panel bundled in the same package as a separate module (Integrations.OpenSearch.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/open_search.ex
defmodule Integrations.OpenSearch do
@moduledoc """
OpenSearch (and Elasticsearch-compatible) cluster monitor.
Collection only — see `Integrations.OpenSearch.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.
Uses two REST endpoints:
* `GET /` — cluster version (non-fatal; skipped if unavailable).
* `GET /_cluster/health` — cluster status (green/yellow/red), shard assignment
counts, node count. Round-trip time is used as `latency_ms`.
* `GET /_cluster/stats` — aggregate document counts, index store size, JVM
heap, OS memory, and cumulative indexing/search totals. Rates are computed
by diffing consecutive totals against state.
Authentication uses HTTP Basic Auth. TLS is enabled by default; set
`verify_tls: false` to accept self-signed certificates (typical for
in-cluster deployments managed by the OpenSearch Operator).
## Params
* `:host` — OpenSearch hostname or IP. Required.
* `:port` — HTTP/HTTPS port. Defaults to `9200`.
* `:user` — HTTP Basic Auth username.
* `:password` — HTTP Basic Auth password.
* `:tls` — Use HTTPS. Defaults to `true`.
* `:verify_tls` — Verify TLS certificates. Defaults to `false`
(self-signed certs are common in Kubernetes).
* `:timeout_ms` — Request timeout in milliseconds. Defaults to `10000`.
* `:latency_degraded_ms` — Round-trip threshold for `:degraded`. Defaults to `1000`.
## Health signal
* `:up` — Cluster status is `green`.
* `:degraded` — Cluster status is `yellow`, unassigned shards exist, or
API latency exceeds threshold.
* `:down` — Connection failed, auth rejected, or cluster status is `red`.
## Metrics
* `latency_ms` — `/_cluster/health` round-trip time
* `node_count` — Total cluster nodes
* `active_shards` — Total active shards
* `unassigned_shards`— Shards not yet assigned to any node
* `docs_count` — Total documents across all indices
* `store_size_bytes` — Total index storage in bytes
* `heap_used_pct` — Aggregate JVM heap pressure (0–100)
* `indexing_rate` — Documents indexed per second (requires two checks)
* `search_rate` — Search queries per second (requires two checks)
"""
use CodeNameRaven.Monitor
@default_port 9200
@default_timeout_ms 10_000
@default_lat_degraded_ms 1_000
@impl true
def params_template do
%{
host: "opensearch.example.com",
port: "9200",
user: "admin",
password: "",
tls: "true",
verify_tls: "false"
}
end
@impl true
def params_schema do
[
host: [type: :string, required: true, doc: "OpenSearch hostname or IP"],
port: [type: :non_neg_integer, default: 9200, doc: "HTTP/HTTPS port"],
user: [type: :string, required: false, doc: "HTTP Basic Auth username"],
password: [type: :string, required: false, doc: "HTTP Basic Auth password"],
tls: [type: :boolean, default: true, doc: "Use HTTPS (recommended)"],
verify_tls: [type: :boolean, default: false, doc: "Verify TLS certificate; disable for self-signed"],
timeout_ms: [type: :non_neg_integer, default: 10_000, doc: "HTTP request timeout in milliseconds"],
latency_degraded_ms: [type: :non_neg_integer, default: 1_000, doc: "Round-trip 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)
tls = truthy?(get_param(params, :tls), default: true)
scheme = if tls, do: "https", else: "http"
if host, do: {:ok, "#{scheme}://#{host}:#{port}/_cluster/health"}, 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)
port = parse_int(get_param(params, :port), @default_port)
user = get_param(params, :user)
password = get_param(params, :password) || ""
tls = truthy?(get_param(params, :tls), default: true)
verify_tls = truthy?(get_param(params, :verify_tls), default: false)
timeout = parse_int(get_param(params, :timeout_ms), @default_timeout_ms)
lat_deg = parse_int(get_param(params, :latency_degraded_ms), @default_lat_degraded_ms)
if is_nil(host) do
{:error, "missing required param :host", state}
else
scheme = if tls, do: "https", else: "http"
base = "#{scheme}://#{host}:#{port}"
ssl_opts = if verify_tls,
do: [verify: :verify_peer, cacerts: :public_key.cacerts_get()],
else: [verify: :verify_none]
req_opts =
[
connect_options: [transport_opts: ssl_opts],
receive_timeout: timeout,
retry: false
]
|> add_auth(user, password)
case run_checks(base, req_opts, state, lat_deg) do
{:ok, result, new_state} -> {:ok, result, new_state}
{:error, reason} -> {:error, reason, state}
end
end
end
# ---------------------------------------------------------------------------
# Healthy?
# ---------------------------------------------------------------------------
@impl true
def healthy?(%{cluster_status: "red"}), do: :down
def healthy?(%{cluster_status: "yellow"}), do: :degraded
def healthy?(%{unassigned_shards: n}) when is_integer(n) and n > 0, do: :degraded
def healthy?(%{latency_ms: lat, latency_degraded_ms: deg})
when is_integer(lat) and is_integer(deg) and lat >= deg,
do: :degraded
def healthy?(_), do: :up
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@impl true
def metrics(result) do
%{latency_ms: result.latency_ms}
|> maybe_put(:node_count, result[:node_count])
|> maybe_put(:active_shards, result[:active_shards])
|> maybe_put(:unassigned_shards, result[:unassigned_shards])
|> maybe_put(:docs_count, result[:docs_count])
|> maybe_put(:store_size_bytes, result[:store_size_bytes])
|> maybe_put(:heap_used_pct, result[:heap_used_pct])
|> maybe_put(:indexing_rate, result[:indexing_rate])
|> maybe_put(:search_rate, result[:search_rate])
end
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
defp run_checks(base, req_opts, state, lat_deg) do
# Non-fatal: fetch version from root endpoint
version =
case Req.get(base <> "/", req_opts) do
{:ok, %{status: 200, body: b}} when is_map(b) -> get_in(b, ["version", "number"])
_ -> nil
end
t0 = System.monotonic_time(:millisecond)
with {:ok, health} <- fetch(base, "/_cluster/health", req_opts) do
latency_ms = System.monotonic_time(:millisecond) - t0
# Non-fatal: cluster stats enrich the result but are not required
cs =
case fetch(base, "/_cluster/stats", req_opts) do
{:ok, s} -> s
_ -> %{}
end
cs_indices = cs["indices"] || %{}
cs_nodes = cs["nodes"] || %{}
docs = cs_indices["docs"] || %{}
store = cs_indices["store"] || %{}
indexing = cs_indices["indexing"] || %{}
search_s = cs_indices["search"] || %{}
jvm_mem = (cs_nodes["jvm"] || %{})["mem"] || %{}
os_mem = (cs_nodes["os"] || %{})["mem"] || %{}
heap_used = jvm_mem["heap_used_in_bytes"]
heap_max = jvm_mem["heap_max_in_bytes"]
heap_pct =
if is_number(heap_used) and is_number(heap_max) and heap_max > 0,
do: round(heap_used / heap_max * 100),
else: nil
index_total = indexing["index_total"]
query_total = search_s["query_total"]
now = DateTime.utc_now()
{indexing_rate, search_rate} = compute_rates(index_total, query_total, now, state)
result = %{
latency_ms: latency_ms,
latency_degraded_ms: lat_deg,
version: version,
cluster_name: health["cluster_name"],
cluster_status: health["status"],
node_count: health["number_of_nodes"],
data_node_count: health["number_of_data_nodes"],
active_shards: health["active_shards"],
active_primary: health["active_primary_shards"],
relocating_shards: health["relocating_shards"],
initializing_shards: health["initializing_shards"],
unassigned_shards: health["unassigned_shards"],
pending_tasks: health["number_of_pending_tasks"],
index_count: cs_indices["count"],
docs_count: docs["count"],
store_size_bytes: store["size_in_bytes"],
heap_used_bytes: heap_used,
heap_max_bytes: heap_max,
heap_used_pct: heap_pct,
os_mem_used_pct: os_mem["used_percent"],
indexing_rate: indexing_rate,
search_rate: search_rate
}
new_state = %{
prev_index_total: index_total,
prev_query_total: query_total,
prev_collected_at: now
}
{:ok, result, new_state}
end
end
defp compute_rates(index_total, query_total, now, state) do
prev_idx = state[:prev_index_total]
prev_qry = state[:prev_query_total]
prev_at = state[:prev_collected_at]
if is_nil(prev_at) or is_nil(prev_idx) do
{nil, nil}
else
elapsed = DateTime.diff(now, prev_at, :millisecond) / 1000.0
if elapsed > 0 do
idx_rate =
if is_integer(index_total) and is_integer(prev_idx) and index_total >= prev_idx,
do: Float.round((index_total - prev_idx) / elapsed, 2)
qry_rate =
if is_integer(query_total) and is_integer(prev_qry) and query_total >= prev_qry,
do: Float.round((query_total - prev_qry) / elapsed, 2)
{idx_rate, qry_rate}
else
{nil, nil}
end
end
end
defp fetch(base, path, req_opts) do
case Req.get(base <> path, req_opts) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
{:ok, body}
{:ok, %{status: 401}} ->
{:error, "authentication failed — check user and password"}
{:ok, %{status: 403}} ->
{:error, "access denied — user may lack cluster:monitor/* permissions"}
{:ok, %{status: status, body: body}} ->
reason =
cond do
is_map(body) ->
err = body["error"]
(is_map(err) && err["reason"]) || (is_binary(err) && err) || body["message"] || "HTTP #{status}"
is_binary(body) ->
body
true ->
"HTTP #{status}"
end
{:error, "API error on #{path}: #{reason}"}
{:error, %{reason: :econnrefused}} ->
{:error, "connection refused — verify host, port, and that the node is running"}
{:error, %{reason: :timeout}} ->
{:error, "request timed out after #{Keyword.get(req_opts, :receive_timeout, @default_timeout_ms)} ms"}
{:error, reason} ->
{:error, format_error(reason)}
end
end
defp add_auth(opts, nil, _password), do: opts
defp add_auth(opts, user, password), do: Keyword.put(opts, :auth, {user, password})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, val), do: Map.put(map, key, val)
defp format_error(%{reason: reason}), do: inspect(reason)
defp format_error(reason), do: inspect(reason)
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
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)
end