Packages

An Elixir utiliy to filter logs

Current section

Files

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

lib/mix/tasks/log.tail.ex

defmodule Mix.Tasks.Log.Tail do
@moduledoc """
Watch a log file in real-time with parsed, color-coded, filtered output.
## Usage
mix log.tail <path> [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