Addresses final code review: - Host collector's /proc reads now go through Diagnostics.log_read/3, appearing in commands.log formatted as `$ cat /proc/loadavg` - configure/1 logs an info line on successful enable so the operator has a breadcrumb in the journal - Writer.init/1 documents the deliberate trap_exit omission
98 lines
2.8 KiB
Elixir
98 lines
2.8 KiB
Elixir
defmodule ProxmoxAgent.Diagnostics.Writer do
|
|
@moduledoc """
|
|
Serializes diagnostic dump writes. Owns two append-only file handles
|
|
for commands.log and samples.log under a configured directory.
|
|
|
|
Started only when the agent's dump_dir is set. All writes are cast-based
|
|
to keep the caller path free of file I/O latency.
|
|
"""
|
|
|
|
use GenServer
|
|
require Logger
|
|
|
|
@spec start_link(keyword()) :: GenServer.on_start()
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
dir = Keyword.fetch!(opts, :dir)
|
|
|
|
# No trap_exit: the Writer owns no linked processes other than the supervisor.
|
|
# If you add a linked port/task, restore Process.flag(:trap_exit, true) and
|
|
# add a matching handle_info({:EXIT, _, _}, state) clause.
|
|
with {:ok, commands} <- open(Path.join(dir, "commands.log")),
|
|
{:ok, samples} <- open(Path.join(dir, "samples.log")) do
|
|
{:ok, %{dir: dir, commands: commands, samples: samples}}
|
|
else
|
|
{:error, reason} ->
|
|
{:stop, {:open_failed, reason}}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:command, cmd, args, result, duration_us}, state) do
|
|
write(state.commands, format_command(cmd, args, result, duration_us))
|
|
{:noreply, state}
|
|
end
|
|
|
|
def handle_cast({:sample, kind, payload}, state) do
|
|
write(state.samples, format_sample(kind, payload))
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:flush, _from, state) do
|
|
{:reply, :ok, state}
|
|
end
|
|
|
|
@impl true
|
|
def terminate(_reason, state) do
|
|
File.close(state.commands)
|
|
File.close(state.samples)
|
|
:ok
|
|
end
|
|
|
|
# --- private ----------------------------------------------------------
|
|
|
|
defp open(path), do: File.open(path, [:append, :utf8])
|
|
|
|
defp write(io, data) do
|
|
case IO.write(io, data) do
|
|
:ok ->
|
|
:ok
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("diagnostics writer: write failed (#{inspect(reason)})")
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp format_command(cmd, args, result, duration_us) do
|
|
header = "===== #{now_iso()} =====\n$ #{cmd} #{Enum.join(args, " ")}\n"
|
|
|
|
status =
|
|
case result do
|
|
{:ok, body} ->
|
|
"exit=0 duration=#{ms(duration_us)}ms size=#{byte_size(body)}\n\n#{body}\n\n"
|
|
|
|
{:error, {:nonzero_exit, code, body}} ->
|
|
"exit=#{code} duration=#{ms(duration_us)}ms size=#{byte_size(body)}\n\n#{body}\n\n"
|
|
|
|
{:error, reason} ->
|
|
"error=#{inspect(reason)} duration=#{ms(duration_us)}ms\n\n"
|
|
end
|
|
|
|
header <> status
|
|
end
|
|
|
|
defp format_sample(kind, payload) do
|
|
body = Jason.encode!(payload, pretty: true)
|
|
"===== #{now_iso()} kind=#{kind} =====\n#{body}\n\n"
|
|
end
|
|
|
|
defp now_iso, do: DateTime.utc_now() |> DateTime.to_iso8601()
|
|
|
|
defp ms(us) when is_integer(us), do: div(us, 1_000)
|
|
end
|