Packages

MCP server exposing live Elixir runtime introspection tools to AI agents.

Current section

Files

Jump to
extools lib extools log_handler.ex
Raw

lib/extools/log_handler.ex

defmodule Extools.LogHandler do
@moduledoc """
A `:logger` handler that buffers formatted log events into an `:ordered_set`
ETS table (`:extools_logs`), keyed by `System.monotonic_time/0`.
The buffer is capped at `@max_entries` entries (default 10_000) so it does not
leak memory in long-running nodes. `get_logs/1` reads the buffer back with
optional `level`, `grep` (substring or `Regex`) and `tail` filters.
Install is idempotent: calling `install/0` multiple times is a no-op.
"""
@table :extools_logs
@max_entries 10_000
@type level ::
:debug | :info | :notice | :warning | :error | :critical | :alert | :emergency | :all
@type grep :: String.t() | Regex.t() | nil
@type opts :: [{:level, level()} | {:grep, grep()} | {:tail, pos_integer()}]
@doc """
Creates the ETS table if missing and attaches the handler to `:logger`.
Safe to call repeatedly.
"""
@spec install :: :ok | {:error, term()}
def install do
ensure_table!()
case :logger.add_handler(:extools_log_handler, __MODULE__, %{
level: :all,
filter_default: :log,
filters: []
}) do
:ok -> :ok
{:error, {:already_exist, :extools_log_handler}} -> :ok
{:error, reason} -> {:error, reason}
end
end
@doc """
Removes the handler (mostly useful in tests).
"""
@spec uninstall :: :ok
def uninstall do
:logger.remove_handler(:extools_log_handler)
:ok
catch
:error, :badarg -> :ok
end
@doc """
Returns the configured log buffer as a list of formatted, most-recent-last
strings, after applying the given filters.
## Options
* `:level` - minimum log level to keep (`:debug`/`:info`/.../:emergency`,
or `:all`). Default `:all`.
* `:grep` - keep only entries whose formatted text contains the given
substring (binary) or matches the given `Regex`. Default `nil`.
* `:tail` - return only the last N entries after filtering. Default `100`.
"""
@spec get_logs(opts()) :: [String.t()]
def get_logs(opts \\ []) do
ensure_table!()
level = Keyword.get(opts, :level, :all)
grep = Keyword.get(opts, :grep)
tail = Keyword.get(opts, :tail, 100)
min_level = level_to_int(level)
:ets.tab2list(@table)
|> Enum.sort_by(fn {key, _} -> key end)
|> Enum.filter(fn {_key, {lvl, _meta, msg}} ->
level_to_int(lvl) >= min_level and matches_grep?(msg, grep)
end)
|> Enum.take(-tail)
|> Enum.map(fn {_key, {_lvl, _meta, msg}} -> msg end)
end
@doc """
Clears all buffered log entries.
"""
@spec clear :: :ok
def clear do
if :ets.whereis(@table) != :undefined do
:ets.delete_all_objects(@table)
end
:ok
end
# --- :logger handler callbacks (called by the logger) ---
# `:logger` calls this with `%{}` as config when adding the handler; we just
# need to return the (possibly updated) config.
def adding_handler(config) do
{:ok, config}
end
def log(event, _config) do
try do
ensure_table!()
write_event(event)
catch
# Never let a logging error crash the logger (and thus the VM).
:error, _ -> :ok
end
end
# --- internals ---
defp write_event(%{level: level, msg: msg, meta: meta}) do
formatted = format_event(level, msg, meta)
key = System.monotonic_time()
:ets.insert(@table, {key, {level, Map.take(meta, [:mfa, :file, :line]), formatted}})
cap_table!()
end
defp format_event(level, msg, meta) do
timestamp =
DateTime.utc_now()
|> DateTime.to_iso8601()
pid =
case meta do
%{pid: pid} -> " pid=#{inspect(pid)}"
_ -> ""
end
body =
case msg do
{:string, str} when is_binary(str) -> str
{:string, str} when is_list(str) -> IO.iodata_to_binary(str)
{:report, report} -> format_report(report, meta)
m when is_binary(m) -> m
other -> inspect(other)
end
"[#{timestamp}] #{String.upcase(to_string(level))}#{pid}: #{body}"
end
defp format_report(report, %{mfa: mfa}) when is_function(mfa, 0) do
"#{inspect(mfa)}: #{inspect(report)}"
end
defp format_report(report, meta) do
mfa_str = Map.get(meta, :mfa, "unknown")
"#{inspect(mfa_str)}: #{inspect(report)}"
end
defp matches_grep?(_msg, nil), do: true
defp matches_grep?(msg, %Regex{} = regex), do: Regex.match?(regex, msg)
defp matches_grep?(msg, substring) when is_binary(substring),
do: String.contains?(msg, substring)
defp matches_grep?(_, _), do: true
@levels %{
debug: 0,
info: 1,
notice: 2,
warning: 3,
error: 4,
critical: 5,
alert: 6,
emergency: 7,
all: -1
}
defp level_to_int(:all), do: -1
defp level_to_int(level) when is_atom(level), do: Map.get(@levels, level, -1)
defp level_to_int(_), do: -1
defp ensure_table! do
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:named_table, :ordered_set, :public, read_concurrency: true])
end
:ok
end
defp cap_table! do
size = :ets.info(@table, :size)
if size > @max_entries do
overflow = size - @max_entries
# Delete the oldest `overflow` entries (smallest keys first).
keys =
:ets.select(@table, [{{:"$1", :_}, [], [:"$1"]}])
|> Enum.sort()
|> Enum.take(overflow)
Enum.each(keys, &:ets.delete(@table, &1))
end
end
end