Packages

An Elixir utiliy to filter logs

Current section

Files

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

lib/mix/tasks/log.report.ex

defmodule Mix.Tasks.Log.Report do
@moduledoc """
Generate a self-contained HTML summary report from log files.
## Usage
mix log.report <path> [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)
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#{esc(title)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; }
.container { max-width: 960px; margin: 0 auto; }
h1 { color: #58a6ff; margin-bottom: 0.5rem; }
.subtitle { color: #8b949e; margin-bottom: 2rem; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; }
.card h2 { color: #58a6ff; font-size: 1rem; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid #21262d; }
.stat-row:last-child { border-bottom: none; }
.stat-label { color: #c9d1d9; }
.stat-value { font-weight: bold; color: #f0f6fc; }
.bar-container { flex: 1; margin: 0 1rem; height: 8px; background: #21262d; border-radius: 4px; }
.bar { height: 100%; border-radius: 4px; }
.bar-error { background: #f85149; }
.bar-warn { background: #d29922; }
.bar-info { background: #3fb950; }
.bar-debug { background: #8b949e; }
.bar-default { background: #58a6ff; }
.full-width { grid-column: 1 / -1; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; color: #8b949e; font-size: 0.85rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid #30363d; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; font-size: 0.9rem; }
.level-error { color: #f85149; }
.level-warn { color: #d29922; }
.level-info { color: #3fb950; }
.level-debug { color: #8b949e; }
.tag { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
.tag-format { background: #1f3044; color: #58a6ff; }
.msg { color: #c9d1d9; word-break: break-word; }
.count { color: #8b949e; font-size: 0.85rem; }
.big-number { font-size: 2.5rem; font-weight: bold; color: #f0f6fc; }
.big-label { color: #8b949e; font-size: 0.85rem; }
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
.summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; text-align: center; }
footer { text-align: center; color: #484f58; margin-top: 2rem; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="container">
<h1>#{esc(title)}</h1>
<p class="subtitle">Source: #{esc(path)} &middot; Generated: #{DateTime.utc_now() |> DateTime.to_string()}</p>
<div class="summary-grid">
#{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")}
</div>
<div class="grid">
<div class="card">
<h2>By Level</h2>
#{level_bars(level_counts, total)}
</div>
<div class="card">
<h2>By Format</h2>
#{format_rows(format_counts, total)}
</div>
#{top_sources_card(top_sources)}
#{top_errors_card(top_errors)}
#{patterns_card(patterns)}
<div class="card full-width">
<h2>Recent Errors &amp; Warnings (last #{min(20, length(errors))})</h2>
<table>
<tr><th>Level</th><th>Format</th><th>Source</th><th>Timestamp</th><th>Message</th></tr>
#{error_rows(Enum.take(errors, 20))}
</table>
</div>
</div>
<footer>Generated by TelemetryData &middot; Elixir Log Analysis</footer>
</div>
</body>
</html>
"""
end
defp summary_card(value, label) do
"""
<div class="summary-card">
<div class="big-number">#{value}</div>
<div class="big-label">#{label}</div>
</div>
"""
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}"
"""
<div class="stat-row">
<span class="stat-label level-#{level}">#{String.upcase(to_string(level))}</span>
<div class="bar-container"><div class="bar #{bar_class}" style="width: #{pct}%"></div></div>
<span class="stat-value">#{count} <span class="count">(#{pct}%)</span></span>
</div>
"""
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
"""
<div class="stat-row">
<span class="stat-label"><span class="tag tag-format">#{format}</span></span>
<div class="bar-container"><div class="bar bar-default" style="width: #{pct}%"></div></div>
<span class="stat-value">#{count}</span>
</div>
"""
end)
|> Enum.join()
end
defp top_sources_card(sources) do
rows =
sources
|> Enum.map(fn {source, count} ->
"<div class=\"stat-row\"><span class=\"stat-label\">#{esc(to_string(source))}</span><span class=\"stat-value\">#{count}</span></div>"
end)
|> Enum.join()
"""
<div class="card">
<h2>Top Sources</h2>
#{rows}
</div>
"""
end
defp top_errors_card(messages) do
rows =
messages
|> Enum.map(fn {msg, count} ->
"<div class=\"stat-row\"><span class=\"msg\">#{esc(String.slice(msg, 0, 80))}</span><span class=\"stat-value\">#{count}</span></div>"
end)
|> Enum.join()
"""
<div class="card">
<h2>Top Error/Warn Messages</h2>
#{rows}
</div>
"""
end
defp error_rows(entries) do
entries
|> Enum.map(fn entry ->
"""
<tr>
<td class="level-#{entry.level}">#{String.upcase(entry.level)}</td>
<td><span class="tag tag-format">#{entry.format}</span></td>
<td>#{esc(to_string(entry.source || "-"))}</td>
<td class="count">#{esc(entry.timestamp)}</td>
<td class="msg">#{esc(entry.message)}</td>
</tr>
"""
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}"
"""
<tr>
<td class="stat-value" style="text-align:right; width:60px">#{p.count}x</td>
<td class="#{level_class}">#{String.upcase(p.level)}</td>
<td><span class="tag tag-format">#{p.format}</span></td>
<td class="msg">#{msg}</td>
<td class="count">#{esc(p.first_seen)}</td>
</tr>
"""
end)
|> Enum.join()
"""
<div class="card full-width">
<h2>Detected Patterns (#{length(patterns)} unique clusters)</h2>
<table>
<tr><th>Count</th><th>Level</th><th>Format</th><th>Pattern</th><th>First Seen</th></tr>
#{rows}
</table>
</div>
"""
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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
end
end