Current section
Files
Jump to
Current section
Files
lib/web/api.ex
defmodule Observer.Web.Api do
@moduledoc """
Read-only JSON API over the Observer Web collectors, mounted with
`Observer.Web.Router.observer_api/2`.
The API is aimed at automation and AI agents: every endpoint returns a bounded, JSON-encoded
snapshot of the same data the dashboard pages render, collected through the existing RPC
layer, so it works against any node in the cluster.
## Endpoints
All endpoints accept an optional `node` query parameter (defaults to the local node). The
value must match a node currently visible in the cluster, anything else is a `404`.
* `GET /` - available endpoints and visible nodes.
* `GET /system` - runtime info, VM limits, allocator utilization (`ObserverWeb.SystemInfo`).
* `GET /processes?sort_by=reductions|memory|message_queue_len&limit=50` - etop-style process
ranking (`ObserverWeb.Processes`). `reductions` are cumulative since process start.
* `GET /ets` - every ETS table with size/memory/owner metadata (`ObserverWeb.Ets`).
* `GET /apps` - running applications with description and version (`ObserverWeb.Apps`).
Access control goes through the same `Observer.Web.Resolver` used by the dashboard:
`{:forbidden, _}` access renders a `403`. Since every endpoint is read-only, both `:all` and
`:read_only` access are accepted.
"""
@behaviour Plug
import Plug.Conn
alias Observer.Web.Resolver
alias ObserverWeb.Ets
alias ObserverWeb.Processes
alias ObserverWeb.SystemInfo
@endpoints ~w(system processes ets apps)
@sort_by_values %{
"reductions" => :reductions,
"memory" => :memory,
"message_queue_len" => :message_queue_len
}
@default_limit 50
@max_limit 1_000
@impl Plug
def init(opts), do: opts
@impl Plug
def call(conn, action: action, resolver: resolver) do
conn = fetch_query_params(conn)
user = Resolver.call_with_fallback(resolver, :resolve_user, [conn])
case Resolver.call_with_fallback(resolver, :resolve_access, [user]) do
{:forbidden, _path} ->
send_json(conn, 403, %{error: "forbidden"})
_read_only_or_all ->
dispatch(conn, action)
end
end
defp dispatch(conn, :index) do
send_json(conn, 200, %{
endpoints: @endpoints,
nodes: Enum.map(known_nodes(), &to_string/1)
})
end
defp dispatch(conn, action) do
case resolve_node(conn.query_params["node"]) do
{:ok, node} -> render(conn, action, node)
:error -> send_json(conn, 404, %{error: "unknown node"})
end
end
defp render(conn, :system, node) do
case SystemInfo.node_info(node) do
{:ok, node_info} ->
send_json(conn, 200, %{
node: to_string(node),
info: node_info,
limits: SystemInfo.limits(node),
allocators: SystemInfo.allocators(node)
})
{:error, reason} ->
send_json(conn, 502, %{error: "could not read node", reason: inspect(reason)})
end
end
defp render(conn, :processes, node) do
sort_by = Map.get(@sort_by_values, conn.query_params["sort_by"], :reductions)
limit = parse_limit(conn.query_params["limit"])
case Processes.sample(node) do
{:ok, sample} ->
rows =
sample
|> Processes.rank(sort_by, limit)
|> Enum.map(fn row ->
row
|> Map.delete(:reductions_diff)
|> Map.update!(:pid, &inspect/1)
end)
send_json(conn, 200, %{
node: to_string(node),
process_count: sample.process_count,
run_queue: sample.run_queue,
memory: sample.memory,
sort_by: sort_by,
processes: rows
})
{:error, reason} ->
send_json(conn, 502, %{error: "could not read node", reason: inspect(reason)})
end
end
defp render(conn, :ets, node) do
case Ets.list_tables(node) do
{:ok, tables} ->
tables =
Enum.map(tables, fn table ->
table
|> Map.update!(:handle, &inspect/1)
|> Map.update!(:owner, &inspect/1)
end)
send_json(conn, 200, %{node: to_string(node), tables: tables})
{:error, reason} ->
send_json(conn, 502, %{error: "could not read node", reason: inspect(reason)})
end
end
defp render(conn, :apps, node) do
send_json(conn, 200, %{node: to_string(node), applications: ObserverWeb.Apps.list(node)})
rescue
# Apps.list is not error-shaped: a node vanishing mid-request surfaces as a raise.
error -> send_json(conn, 502, %{error: "could not read node", reason: inspect(error)})
end
# The node query value only becomes a node if it matches a currently known node - crafted
# values never reach RPC and never allocate atoms.
defp resolve_node(nil), do: {:ok, Node.self()}
defp resolve_node(value) do
case Enum.find(known_nodes(), &(to_string(&1) == value)) do
nil -> :error
node -> {:ok, node}
end
end
defp known_nodes, do: [Node.self() | Node.list()]
defp parse_limit(value) do
case Integer.parse(value || "") do
{limit, ""} when limit > 0 -> min(limit, @max_limit)
_invalid -> @default_limit
end
end
# sobelow_skip ["XSS.SendResp"]
# Sobelow flags any send_resp/2 body as potential XSS since it can't verify the response is
# non-HTML. The content-type is explicitly "application/json" above and every payload is
# Jason-encoded, so there's no HTML for a browser to sniff or render.
defp send_json(conn, status, payload) do
conn
|> put_resp_header("content-type", "application/json; charset=utf-8")
|> send_resp(status, Jason.encode!(payload))
|> halt()
end
end