lib/grizzly/trace.ex

defmodule Grizzly.Trace do
  @moduledoc """
  Module that tracks the commands that are sent and received by Grizzly

  The trace will hold in memory the last 300 messages. If you want to generate
  a log file of the trace records you use `Grizzly.Trace.dump/1`.

  The log format is:

  ```
  timestamp source destination sequence_number command_name command_parameters
  ```

  If you want to list the records that are currently being held in memory you
  can use `Grizzly.Trace.list/0`.

  If you want to start traces from a fresh start you can call
  `Grizzly.Trace.clear/0`.
  """

  use GenServer

  alias Grizzly.Trace.Record

  @type trace_opt :: {:name, atom()} | {:size, pos_integer()} | {:record_keepalives, boolean()}

  @type src() :: String.t()
  @type dest() :: String.t()

  @type log_opt() :: {:src, src()} | {:dest, dest()}

  @type format() :: :text | :term

  @default_size 300
  @default_format :text

  @doc """
  Start the trace server
  """
  @spec start_link([trace_opt()]) :: GenServer.on_start()
  def start_link(opts \\ []) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  @doc """
  Serialize a list of trace records into a binary. The format can be one of:

  * `:text` - Each record is on a new line and is
    formatted as `timestamp source destination sequence_number command_name
    command_binary`. This is the default format.

  * `:raw` - Each record is on a new line and is formatted as
    `timestamp source -> destination: binary`.

  * `:term` - The trace is serialized as a list of `Record.t()` structs in
    Erlang external term format. This is useful if you want to load the trace
    on another machine.
  """
  @spec format([Record.t()], format()) :: binary()
  def format(records, format \\ @default_format)

  def format(records, fmt) when fmt in [:text, :raw],
    do: Enum.map_join(records, "\n", &Record.to_string(&1, fmt))

  def format(records, :term), do: :erlang.term_to_binary(records)

  @doc """
  Dump trace records into a file. See `format/2` for the available formats.
  """
  @spec dump(binary(), format()) :: :ok | {:error, atom()}
  def dump(file, format \\ @default_format) do
    file_contents = format(list(), format)
    File.write(file, file_contents)
  end

  @doc """
  Write trace records to standard out. See `format/2` for the available formats.
  """
  @spec print(list(Record.t()), format()) :: :ok
  def print(records, :term), do: print(records, @default_format)

  def print(records, fmt) do
    IO.puts(format(records, fmt))
  end

  @spec print(list(Record.t()) | format()) :: :ok
  def print(records) when is_list(records), do: print(records, @default_format)
  def print(fmt) when is_atom(fmt), do: print(list(), fmt)

  @spec print() :: :ok
  def print(), do: print(list(), @default_format)

  @doc """
  Add a record to the trace buffer.
  """
  @spec log(GenServer.name(), binary(), [log_opt()]) :: :ok
  def log(name, binary, opts) do
    GenServer.cast(name, {:log, binary, opts})
  end

  @doc "See `log/3`"
  @spec log(binary(), [log_opt()]) :: :ok
  def log(binary, opts \\ []) when is_binary(binary), do: log(__MODULE__, binary, opts)

  @doc """
  Reset the trace buffer.
  """
  @spec clear(GenServer.name()) :: :ok
  def clear(name \\ __MODULE__) do
    GenServer.call(name, :clear)
  end

  @doc """
  List all records in the trace buffer.
  """
  @spec list(GenServer.name()) :: [Record.t()]
  def list(name \\ __MODULE__) do
    GenServer.call(name, :list)
  end

  @doc """
  Change the max size of the trace buffer from the default of #{@default_size}.
  """
  @spec resize(GenServer.name(), pos_integer()) :: :ok
  def resize(name \\ __MODULE__, size) do
    GenServer.call(name, {:resize, size})
  end

  @doc """
  Enable or disable logging of keepalive frames.
  """
  @spec record_keepalives(GenServer.name(), boolean()) :: :ok
  def record_keepalives(name \\ __MODULE__, enabled? \\ true) do
    GenServer.call(name, {:record_keepalives, enabled?})
  end

  @impl GenServer
  def init(opts) do
    size = Keyword.get(opts, :size, @default_size)
    record_keepalives = Keyword.get(opts, :record_keepalives, true)
    {:ok, %{buffer: CircularBuffer.new(size), record_keepalives: record_keepalives}}
  end

  @impl GenServer
  def handle_cast({:log, <<0x23, 0x03, _ack_flag>>, _opts}, %{record_keepalives: false} = state) do
    {:noreply, state}
  end

  def handle_cast({:log, binary, opts}, state) do
    record = Record.new(binary, opts)

    {:noreply, %{state | buffer: CircularBuffer.insert(state.buffer, record)}}
  end

  @impl GenServer
  def handle_call(:clear, _from, %{buffer: %CircularBuffer{max_size: size}} = state) do
    {:reply, :ok, %{state | buffer: CircularBuffer.new(size)}}
  end

  def handle_call(:list, _from, %{buffer: buffer} = state) do
    {:reply, CircularBuffer.to_list(buffer), state}
  end

  def handle_call({:record_keepalives, enabled?}, _from, state) do
    {:reply, :ok, %{state | record_keepalives: enabled?}}
  end

  def handle_call({:resize, size}, _from, %{buffer: buffer} = state) do
    new_buffer =
      buffer
      |> CircularBuffer.to_list()
      |> Enum.reduce(CircularBuffer.new(size), fn record, buffer ->
        CircularBuffer.insert(buffer, record)
      end)

    {:reply, :ok, %{state | buffer: new_buffer}}
  end
end