defmodule Mix.Tasks.Log.Report do @moduledoc """ Generate a self-contained HTML summary report from log files. ## Usage mix log.report [options] ## Options --output Output file path (default: log_report.html) --title Report title (default: "Log Analysis Report") ## Examples mix log.report logs/app.log mix log.report logs/ --output my_report.html --title "Production Logs" """ @shortdoc "Generate an HTML log analysis report" use Mix.Task alias TelemetryData.{FileReader, PatternDetector} @impl Mix.Task def run(args) do {opts, positional, _} = OptionParser.parse(args, strict: [output: :string, title: :string]) path = List.first(positional) || "logs/" output = Keyword.get(opts, :output, "log_report.html") title = Keyword.get(opts, :title, "Log Analysis Report") entries = load_entries(path) html = build_html(entries, title, path) File.write!(output, html) Mix.shell().info("Report generated: #{output} (#{length(entries)} entries)") 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 build_html(entries, title, path) do total = length(entries) level_counts = count_by(entries, :level) format_counts = count_by(entries, :format) top_sources = top_by(entries, :source, 10) errors = entries |> Enum.filter(&(&1.level in ["error", "warn"])) |> Enum.reverse() top_errors = top_messages(errors, 10) patterns = PatternDetector.detect(entries, max_samples: 2) |> Enum.take(15) """ #{esc(title)}

#{esc(title)}

Source: #{esc(path)} · Generated: #{DateTime.utc_now() |> DateTime.to_string()}

#{summary_card(total, "Total Entries")} #{summary_card(count_for(level_counts, "error"), "Errors")} #{summary_card(count_for(level_counts, "warn"), "Warnings")} #{summary_card(length(format_counts), "Formats")}

By Level

#{level_bars(level_counts, total)}

By Format

#{format_rows(format_counts, total)}
#{top_sources_card(top_sources)} #{top_errors_card(top_errors)} #{patterns_card(patterns)}

Recent Errors & Warnings (last #{min(20, length(errors))})

#{error_rows(Enum.take(errors, 20))}
LevelFormatSourceTimestampMessage
Generated by TelemetryData · Elixir Log Analysis
""" end defp summary_card(value, label) do """
#{value}
#{label}
""" end defp level_bars(counts, total) do counts |> Enum.map(fn {level, count} -> pct = if total > 0, do: Float.round(count / total * 100, 1), else: 0 bar_class = "bar-#{level}" """
#{String.upcase(to_string(level))}
#{count} (#{pct}%)
""" end) |> Enum.join() end defp format_rows(counts, total) do counts |> Enum.map(fn {format, count} -> pct = if total > 0, do: Float.round(count / total * 100, 1), else: 0 """
#{format}
#{count}
""" end) |> Enum.join() end defp top_sources_card(sources) do rows = sources |> Enum.map(fn {source, count} -> "
#{esc(to_string(source))}#{count}
" end) |> Enum.join() """

Top Sources

#{rows}
""" end defp top_errors_card(messages) do rows = messages |> Enum.map(fn {msg, count} -> "
#{esc(String.slice(msg, 0, 80))}#{count}
" end) |> Enum.join() """

Top Error/Warn Messages

#{rows}
""" end defp error_rows(entries) do entries |> Enum.map(fn entry -> """ #{String.upcase(entry.level)} #{entry.format} #{esc(to_string(entry.source || "-"))} #{esc(entry.timestamp)} #{esc(entry.message)} """ end) |> Enum.join() end defp patterns_card([]), do: "" defp patterns_card(patterns) do rows = patterns |> Enum.map(fn p -> msg = extract_message(p.signature) |> esc() |> String.slice(0, 80) level_class = "level-#{p.level}" """ #{p.count}x #{String.upcase(p.level)} #{p.format} #{msg} #{esc(p.first_seen)} """ end) |> Enum.join() """

Detected Patterns (#{length(patterns)} unique clusters)

#{rows}
CountLevelFormatPatternFirst Seen
""" end defp extract_message(signature) do case String.split(signature, ":", parts: 3) do [_, _, msg] -> msg _ -> signature end 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 top_messages(entries, n) do entries |> Enum.group_by(& &1.message) |> Enum.map(fn {msg, items} -> {msg, length(items)} end) |> Enum.sort_by(fn {_, count} -> -count end) |> Enum.take(n) end defp count_for(counts, key) do case List.keyfind(counts, key, 0) do {_, count} -> count nil -> 0 end end defp esc(str) do str |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) end end