defmodule Mix.Tasks.Log.Stats do @moduledoc """ Show summary statistics for log files. ## Usage mix log.stats [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