Packages

An Elixir utiliy to filter logs

Current section

Files

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

lib/mix/tasks/log.analyze.ex

defmodule Mix.Tasks.Log.Analyze do
@moduledoc """
Analyze log files — combines search and stats into a quick overview.
## Usage
mix log.analyze <path> [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