defmodule DaProductApp.WeeklyLogger do @moduledoc """ A custom Logger backend that writes all log messages to daily rotating files in Apache format. Files are stored in the `logs/` directory at the project root and are named using the calendar date, e.g.: logs/2026-06-15.log Features: - Logs are formatted in Apache style: [Day Mon DD HH:MM:SS.microseconds YYYY] [level] [pid PID] [client IP:PORT] message - Current day's log remains as `.log` file - When a new day starts, the previous day's log is automatically compressed to `.gz` - Compressed logs are kept indefinitely; nothing is ever deleted automatically """ @behaviour :gen_event @logs_dir "logs" defstruct [:file, :path, :date, :pid] # --------------------------------------------------------------------------- # :gen_event callbacks # --------------------------------------------------------------------------- def init(__MODULE__) do try do {:ok, open_file(%__MODULE__{pid: System.pid()})} rescue e -> IO.warn("WeeklyLogger init failed: #{inspect(e)}. Console logging will continue.") {:ok, %__MODULE__{pid: System.pid(), file: nil}} end end def init({__MODULE__, _opts}) do try do {:ok, open_file(%__MODULE__{pid: System.pid()})} rescue e -> IO.warn("WeeklyLogger init failed: #{inspect(e)}. Console logging will continue.") {:ok, %__MODULE__{pid: System.pid(), file: nil}} end end def handle_event({level, _gl, {Logger, msg, timestamp, metadata}}, state) do try do # Skip writing if file is not initialized (e.g., init failed) if is_nil(state.file) do {:ok, state} else state = maybe_rotate(state) formatted = DaProductApp.LogFormatter.format_entry(level, msg, timestamp, metadata, state.pid) case :file.write(state.file, formatted) do :ok -> {:ok, state} {:error, reason} -> IO.warn("WeeklyLogger write failed: #{inspect(reason)}. Console logging continues.") {:ok, state} end end rescue e -> IO.warn("WeeklyLogger handle_event failed: #{inspect(e)}. Continuing with console logging.") {:ok, state} end end def handle_event(:flush, state), do: {:ok, state} def handle_event(_event, state), do: {:ok, state} def handle_call({:configure, _opts}, state), do: {:ok, :ok, state} def handle_info(_msg, state), do: {:ok, state} def code_change(_old_vsn, state, _extra), do: {:ok, state} def terminate(_reason, %__MODULE__{file: file}) when not is_nil(file) do try do case :file.close(file) do :ok -> :ok {:error, reason} -> IO.warn("WeeklyLogger terminate: failed to close file: #{inspect(reason)}") end rescue e -> IO.warn("WeeklyLogger terminate error: #{inspect(e)}") end end def terminate(_reason, _state), do: :ok # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp open_file(state) do try do # Create logs directory with error handling case File.mkdir_p(@logs_dir) do :ok -> # Compress any old .log files from previous days at startup compress_old_logs() today = Date.utc_today() filename = "#{today}.log" path = Path.join(@logs_dir, filename) case :file.open(String.to_charlist(path), [:append, :binary]) do {:ok, file} -> %{state | file: file, path: path, date: today} {:error, reason} -> IO.warn("WeeklyLogger failed to open file #{path}: #{inspect(reason)}. Logs will not be written to file.") %{state | file: nil, path: path, date: Date.utc_today()} end {:error, reason} -> IO.warn("WeeklyLogger failed to create logs directory: #{inspect(reason)}. Logs will not be written to file.") %{state | file: nil, path: "", date: Date.utc_today()} end rescue e -> IO.warn("WeeklyLogger open_file failed: #{inspect(e)}. Logs will not be written to file.") %{state | file: nil, path: "", date: Date.utc_today()} end end defp maybe_rotate(%__MODULE__{date: date, path: old_path} = state) do today = Date.utc_today() if Date.compare(today, date) == :eq do state else try do # Close the file and compress the previous day's log if not is_nil(state.file) do case :file.close(state.file) do :ok -> :ok {:error, reason} -> IO.warn("Failed to close log file: #{inspect(reason)}") end end compress_file(old_path) rescue e -> IO.warn("WeeklyLogger rotation failed: #{inspect(e)}. Attempting to continue.") end open_file(%__MODULE__{pid: state.pid}) end end defp compress_file(log_path) when is_binary(log_path) do if File.exists?(log_path) do gz_path = log_path <> ".gz" try do data = File.read!(log_path) gz_data = compress_data(data) File.write!(gz_path, gz_data) File.rm!(log_path) :ok rescue e -> IO.warn("Failed to compress #{log_path}: #{inspect(e)}") :ok catch _ -> IO.warn("Error compressing #{log_path}") :ok end end end defp compress_file(_), do: :ok defp compress_data(data) do try do :zlib.gzip(data) rescue e -> IO.warn("Zlib compression failed: #{inspect(e)}") data end end defp compress_old_logs do today = Date.utc_today() try do @logs_dir |> File.ls!() |> Enum.filter(&String.ends_with?(&1, ".log")) |> Enum.each(&maybe_compress_old_log(&1, today)) rescue _ -> :ok end end defp maybe_compress_old_log(filename, today) do # Extract date from filename: YYYY-MM-DD.log date_part = String.trim_trailing(filename, ".log") case Date.from_iso8601(date_part) do {:ok, file_date} -> if Date.compare(file_date, today) == :lt do full_path = Path.join(@logs_dir, filename) compress_file(full_path) end _ -> :ok end rescue _ -> :ok end end