Packages

An Elixir utiliy to filter logs

Current section

Files

Jump to
telemetry_data lib mix tasks log.stats.ex
Raw

lib/mix/tasks/log.stats.ex

defmodule Mix.Tasks.Log.Stats do
@moduledoc """
Show summary statistics for log files.
## Usage
mix log.stats <path> [options]
## Options
--top Number of top items to show per category (default: 5)
## Examples
mix log.stats logs/app.log
mix log.stats logs/ --top 10
"""
@shortdoc "Show log file statistics"
use Mix.Task
alias TelemetryData.FileReader
@impl Mix.Task
def run(args) do
{opts, positional, _} = OptionParser.parse(args, strict: [top: :integer])
path = List.first(positional) || "logs/"
top_n = Keyword.get(opts, :top, 5)
entries = load_entries(path)
total = length(entries)
if total == 0 do
Mix.shell().info("No log entries found.")
else
Mix.shell().info("\n#{IO.ANSI.bright()}Log Statistics#{IO.ANSI.reset()} (#{total} entries)\n")
print_section("By Level", count_by(entries, :level), total)
print_section("By Format", count_by(entries, :format), total)
print_top_section("Top Sources", top_by(entries, :source, top_n))
print_top_section("Top Error Messages", top_errors(entries, top_n))
end
end
defp load_entries(path) do
if File.dir?(path) do
case FileReader.read_dir(path) do
{:ok, entries} -> entries
{:error, reason} -> Mix.raise("Failed to read directory #{path}: #{reason}")
end
else
case FileReader.read_file(path) do
{:ok, entries} -> entries
{:error, reason} -> Mix.raise("Failed to read file #{path}: #{reason}")
end
end
end
defp count_by(entries, field) do
entries
|> Enum.group_by(&Map.get(&1, field))
|> Enum.map(fn {key, items} -> {key, length(items)} end)
|> Enum.sort_by(fn {_, count} -> -count end)
end
defp top_by(entries, field, n) do
entries
|> Enum.filter(&(Map.get(&1, field) != nil))
|> Enum.group_by(&Map.get(&1, field))
|> Enum.map(fn {key, items} -> {key, length(items)} end)
|> Enum.sort_by(fn {_, count} -> -count end)
|> Enum.take(n)
end
defp top_errors(entries, n) do
entries
|> Enum.filter(&(&1.level in ["error", "warn", "warning"]))
|> Enum.group_by(& &1.message)
|> Enum.map(fn {msg, items} -> {msg, length(items)} end)
|> Enum.sort_by(fn {_, count} -> -count end)
|> Enum.take(n)
end
defp print_section(title, counts, total) do
Mix.shell().info(" #{IO.ANSI.bright()}#{title}#{IO.ANSI.reset()}")
Enum.each(counts, fn {key, count} ->
pct = Float.round(count / total * 100, 1)
bar = String.duplicate("█", round(pct / 2))
Mix.shell().info(" #{pad(to_string(key), 12)} #{pad_num(count, 4)} #{bar} #{pct}%")
end)
Mix.shell().info("")
end
defp print_top_section(title, counts) do
if counts == [] do
:ok
else
Mix.shell().info(" #{IO.ANSI.bright()}#{title}#{IO.ANSI.reset()}")
Enum.each(counts, fn {key, count} ->
label = String.slice(to_string(key), 0, 60)
Mix.shell().info(" #{pad_num(count, 4)}x #{label}")
end)
Mix.shell().info("")
end
end
defp pad(str, width) do
String.pad_trailing(str, width)
end
defp pad_num(num, width) do
num |> to_string() |> String.pad_leading(width)
end
end