defmodule Mix.Tasks.Log.Tail do @moduledoc """ Watch a log file in real-time with parsed, color-coded, filtered output. ## Usage mix log.tail [options] ## Options --level Only show entries matching this level (error, warn, info, debug) --format Only show entries matching this format (morgan, java, elixir, nginx) --grep Only show entries whose message contains this text (case-insensitive) --history Show N existing lines from end of file before tailing (default: 0) ## Examples mix log.tail logs/app.log mix log.tail logs/app.log --level error mix log.tail logs/app.log --grep "timeout" --level error mix log.tail logs/app.log --format java --history 20 Press Ctrl+C to stop. """ @shortdoc "Watch a log file in real-time" use Mix.Task alias TelemetryData.LogTail @impl Mix.Task def run(args) do {opts, positional, _} = OptionParser.parse(args, strict: [level: :string, format: :string, grep: :string, history: :integer] ) path = List.first(positional) || "logs/app.log" history = Keyword.get(opts, :history, 0) unless File.exists?(path) do Mix.raise("File not found: #{path}") end filters = opts |> Keyword.take([:level, :format, :grep]) |> Enum.filter(fn {_k, v} -> v != nil end) # Print header Mix.shell().info("") Mix.shell().info(IO.ANSI.bright() <> " Tailing: #{path}" <> IO.ANSI.reset()) if filters != [] do filter_desc = filters |> Enum.map(fn {k, v} -> "#{k}=#{v}" end) |> Enum.join(", ") Mix.shell().info(IO.ANSI.faint() <> " Filters: #{filter_desc}" <> IO.ANSI.reset()) end Mix.shell().info(IO.ANSI.faint() <> " Press Ctrl+C to stop" <> IO.ANSI.reset()) Mix.shell().info("") # Show history if requested if history > 0, do: show_history(path, history, filters) # Start tailing {:ok, _pid} = LogTail.start_link(path, callback: &print_entry/1, filters: filters ) # Block forever (until Ctrl+C) Process.sleep(:infinity) end defp show_history(path, n, filters) do alias TelemetryData.LogParser case File.read(path) do {:ok, content} -> entries = LogParser.parse_lines(content) filtered = Enum.filter(entries, fn entry -> Enum.all?(filters, fn {:level, level} -> entry.level == String.downcase(level) {:format, format} -> entry.format == String.to_existing_atom(format) {:grep, pattern} -> String.contains?(String.downcase(entry.message), String.downcase(pattern)) _ -> true end) end) history_entries = Enum.take(filtered, -n) if history_entries != [] do Mix.shell().info(IO.ANSI.faint() <> " --- history (last #{length(history_entries)}) ---" <> IO.ANSI.reset()) Enum.each(history_entries, &print_entry/1) Mix.shell().info(IO.ANSI.faint() <> " --- live tail ---" <> IO.ANSI.reset()) Mix.shell().info("") end _ -> :ok end end defp print_entry(entry) do level_tag = colorize_level(entry.level) format_tag = IO.ANSI.cyan() <> "[#{entry.format}]" <> IO.ANSI.reset() source = if entry.source, do: IO.ANSI.faint() <> " #{entry.source}" <> IO.ANSI.reset(), else: "" time = IO.ANSI.faint() <> entry.timestamp <> IO.ANSI.reset() IO.puts("#{level_tag} #{format_tag}#{source} #{time} #{entry.message}") end defp colorize_level("error"), do: IO.ANSI.red() <> "[ERROR]" <> IO.ANSI.reset() defp colorize_level("warn"), do: IO.ANSI.yellow() <> "[WARN] " <> IO.ANSI.reset() defp colorize_level("info"), do: IO.ANSI.green() <> "[INFO] " <> IO.ANSI.reset() defp colorize_level("debug"), do: IO.ANSI.faint() <> "[DEBUG]" <> IO.ANSI.reset() defp colorize_level(other), do: "[#{String.upcase(other)}]" end