Packages

An Elixir utiliy to filter logs

Current section

Files

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

lib/mix/tasks/log.search.ex

defmodule Mix.Tasks.Log.Search do
@moduledoc """
Search and filter log entries from files.
## Usage
mix log.search <path> [options]
## Options
--level Filter by log level (info, warn, error, debug)
--format Filter by log format (morgan, java, elixir, nginx)
--source Filter by source (substring match)
--grep Filter by message content (substring match, case-insensitive)
--limit Maximum number of results (default: 50)
## Examples
mix log.search logs/app.log
mix log.search logs/app.log --level error
mix log.search logs/app.log --format java --level warn
mix log.search logs/ --grep "timeout" --limit 10
"""
@shortdoc "Search and filter log entries"
use Mix.Task
alias TelemetryData.FileReader
@impl Mix.Task
def run(args) do
{opts, positional, _} =
OptionParser.parse(args,
strict: [level: :string, format: :string, source: :string, grep: :string, limit: :integer]
)
path = List.first(positional) || "logs/"
limit = Keyword.get(opts, :limit, 50)
entries = load_entries(path)
filtered = apply_filters(entries, opts)
total = length(filtered)
display = Enum.take(filtered, limit)
Mix.shell().info("\n#{IO.ANSI.bright()}Found #{total} entries#{IO.ANSI.reset()} (showing #{min(limit, total)})\n")
Enum.each(display, &print_entry/1)
if total > limit do
Mix.shell().info("\n... #{total - limit} more entries (use --limit to see more)")
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])
|> maybe_filter(:source, opts[:source])
|> maybe_filter(:grep, opts[:grep])
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 maybe_filter(entries, :source, source) do
source_down = String.downcase(source)
Enum.filter(entries, fn entry ->
entry.source && String.contains?(String.downcase(entry.source), source_down)
end)
end
defp maybe_filter(entries, :grep, pattern) do
pattern_down = String.downcase(pattern)
Enum.filter(entries, fn entry ->
String.contains?(String.downcase(entry.message), pattern_down)
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: ""
Mix.shell().info("#{level_tag} #{format_tag}#{source} #{entry.timestamp}")
Mix.shell().info(" #{entry.message}\n")
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("warning"), 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