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) 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