defmodule Mix.Tasks.Log.Analyze do @moduledoc """ Analyze log files — combines search and stats into a quick overview. ## Usage mix log.analyze [options] ## Options --level Focus on a specific level (error, warn, info, debug) --format Focus on a specific format (morgan, java, elixir, nginx) --top Number of top items to show (default: 5) ## Examples mix log.analyze logs/app.log mix log.analyze logs/ --level error mix log.analyze logs/app.log --format java """ @shortdoc "Analyze log files (overview + errors + stats)" use Mix.Task alias TelemetryData.{FileReader, PatternDetector} @impl Mix.Task def run(args) do {opts, positional, _} = OptionParser.parse(args, strict: [level: :string, format: :string, top: :integer] ) path = List.first(positional) || "logs/" top_n = Keyword.get(opts, :top, 5) entries = load_entries(path) filtered = apply_filters(entries, opts) total = length(entries) filtered_count = length(filtered) Mix.shell().info("") Mix.shell().info(IO.ANSI.bright() <> "══════════════════════════════════════" <> IO.ANSI.reset()) Mix.shell().info(IO.ANSI.bright() <> " Log Analysis: #{path}" <> IO.ANSI.reset()) Mix.shell().info(IO.ANSI.bright() <> "══════════════════════════════════════" <> IO.ANSI.reset()) Mix.shell().info("") # Overview Mix.shell().info(" Total entries: #{total}") if filtered_count != total do Mix.shell().info(" After filters: #{filtered_count}") end Mix.shell().info("") # Level breakdown level_counts = count_by(filtered, :level) Mix.shell().info(IO.ANSI.bright() <> " Level Breakdown" <> IO.ANSI.reset()) Enum.each(level_counts, fn {level, count} -> pct = Float.round(count / max(filtered_count, 1) * 100, 1) tag = colorize_level(level) bar = String.duplicate("█", round(pct / 2)) Mix.shell().info(" #{tag} #{String.pad_leading(to_string(count), 4)} #{bar} #{pct}%") end) Mix.shell().info("") # Format breakdown format_counts = count_by(filtered, :format) Mix.shell().info(IO.ANSI.bright() <> " Format Breakdown" <> IO.ANSI.reset()) Enum.each(format_counts, fn {format, count} -> Mix.shell().info(" #{String.pad_trailing(to_string(format), 10)} #{count}") end) Mix.shell().info("") # Recent errors errors = Enum.filter(filtered, &(&1.level in ["error", "warn", "warning"])) if errors != [] do Mix.shell().info(IO.ANSI.bright() <> " Recent Errors/Warnings (last #{min(top_n, length(errors))})" <> IO.ANSI.reset()) errors |> Enum.take(-top_n) |> Enum.reverse() |> Enum.each(fn entry -> level_tag = colorize_level(entry.level) format_tag = IO.ANSI.cyan() <> "[#{entry.format}]" <> IO.ANSI.reset() Mix.shell().info(" #{level_tag} #{format_tag} #{entry.timestamp}") Mix.shell().info(" #{entry.message}") end) Mix.shell().info("") end # Top patterns patterns = PatternDetector.detect(filtered, max_samples: 1, min_count: 1) if patterns != [] do display_patterns = Enum.take(patterns, top_n) Mix.shell().info(IO.ANSI.bright() <> " Top Patterns (#{length(patterns)} unique)" <> IO.ANSI.reset()) Enum.each(display_patterns, fn pattern -> level_tag = colorize_level(pattern.level) sig = extract_message(pattern.signature) |> String.slice(0, 60) Mix.shell().info(" #{level_tag} #{String.pad_leading(to_string(pattern.count), 4)}x #{sig}") end) Mix.shell().info("") end # Top sources top_sources = top_by(filtered, :source, top_n) if top_sources != [] do Mix.shell().info(IO.ANSI.bright() <> " Top Sources" <> IO.ANSI.reset()) Enum.each(top_sources, fn {source, count} -> Mix.shell().info(" #{String.pad_leading(to_string(count), 4)}x #{source}") end) Mix.shell().info("") end Mix.shell().info(IO.ANSI.bright() <> "══════════════════════════════════════" <> IO.ANSI.reset()) Mix.shell().info("") end defp extract_message(signature) do case String.split(signature, ":", parts: 3) do [_, _, msg] -> msg _ -> signature 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 apply_filters(entries, opts) do entries |> maybe_filter(:level, opts[:level]) |> maybe_filter(:format, opts[:format]) end defp maybe_filter(entries, _key, nil), do: entries defp maybe_filter(entries, :level, level) do level = String.downcase(level) Enum.filter(entries, &(&1.level == level)) end defp maybe_filter(entries, :format, format) do format = String.to_existing_atom(format) Enum.filter(entries, &(&1.format == format)) 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 colorize_level("error"), do: IO.ANSI.red() <> String.pad_trailing("[ERROR]", 9) <> IO.ANSI.reset() defp colorize_level("warn"), do: IO.ANSI.yellow() <> String.pad_trailing("[WARN]", 9) <> IO.ANSI.reset() defp colorize_level("warning"), do: IO.ANSI.yellow() <> String.pad_trailing("[WARN]", 9) <> IO.ANSI.reset() defp colorize_level("info"), do: IO.ANSI.green() <> String.pad_trailing("[INFO]", 9) <> IO.ANSI.reset() defp colorize_level("debug"), do: IO.ANSI.faint() <> String.pad_trailing("[DEBUG]", 9) <> IO.ANSI.reset() defp colorize_level(other), do: String.pad_trailing("[#{String.upcase(other)}]", 9) end