defmodule LoggerFileBackendWin do @moduledoc """ `LoggerFileBackend` is a custom backend for the elixir `:logger` application. """ @behaviour :gen_event @type path :: String.t() @type dir :: String.t() @type filename :: String.t() @type file_num :: non_neg_integer() @type file :: :file.io_device() @type format :: String.t() @type level :: Logger.level() @type metadata :: [atom] require Record Record.defrecordp(:file_info, Record.extract(:file_info, from_lib: "kernel/include/file.hrl")) @default_format "$time $metadata[$level] $message\n" def init({__MODULE__, name}) do {:ok, configure(name, [])} end def handle_call({:configure, opts}, %{name: name} = state) do {:ok, :ok, configure(name, opts, state)} end def handle_call(:path, %{path: path} = state) do {:ok, {:ok, path}, state} end def handle_call( {:path_for_file_num, file_num}, %{dir: dir, filename: filename, date: date} = state ) do {:ok, {:ok, path(dir, filename, date, file_num)}, state} end def handle_event( {level, _gl, {Logger, msg, ts, md}}, %{level: min_level, metadata_filter: metadata_filter} = state ) do if (is_nil(min_level) or Logger.compare_levels(level, min_level) != :lt) and metadata_matches?(md, metadata_filter) do log_event(level, msg, ts, md, state) else {:ok, state} end end def handle_event(:flush, state) do # We're not buffering anything so this is a no-op {:ok, state} end def handle_info( :date_rotate, %{ dir: dir, filename: filename, date: date, rotate: %{daily: true} } = state ) do today = local_date() if Date.compare(today, date) == :gt do days = state[:rotate][:days] || 0 delete_files_for_date(dir, filename, Date.add(date, -days - 1)) send_date_rotate(today) path = path(dir, filename, today, 0) {:ok, %{state | date: today, file_num: 0, path: path}} else send_date_rotate(date) {:ok, state} end end def handle_info(_, state) do {:ok, state} end # helpers defp send_date_rotate(date) do tomorrow = date |> Date.add(1) |> DateTime.new!(~T[00:00:00.000000]) case DateTime.diff(tomorrow, local(), :millisecond) do t when t > 0 -> Process.send_after(self(), :date_rotate, t) _ -> send(self(), :date_rotate) end end defp log_event(_level, _msg, _ts, _md, %{path: nil} = state) do {:ok, state} end defp log_event(level, msg, ts, md, %{path: path, io_device: nil} = state) when is_binary(path) do case open_log(path) do {:ok, io_device} -> log_event(level, msg, ts, md, %{state | io_device: io_device}) _other -> {:ok, state} end end defp log_event( level, msg, ts, md, %{ file_num: file_num, io_device: io_device } = state ) do state = %{path: path} = rotate(state) if state[:file_num] == file_num and File.exists?(path) do output = format_event(level, msg, ts, md, state) try do IO.write(io_device, output) {:ok, state} rescue ErlangError -> case open_log(path) do {:ok, io_device} -> IO.write(io_device, prune(output)) {:ok, %{state | io_device: io_device}} _other -> {:ok, %{state | io_device: nil}} end end else File.close(io_device) log_event(level, msg, ts, md, %{state | io_device: nil}) end end defp rotate( %{ path: path, rotate: %{max_bytes: max_bytes, keep: keep} } = state ) when is_integer(max_bytes) and is_integer(keep) and keep > 0 do case :file.read_file_info(path, [:raw]) do {:ok, file_info(size: size)} when size >= max_bytes -> %{date: date, dir: dir, filename: filename, file_num: file_num} = state delete_old_files(dir, filename, file_num + 1, date, keep) %{state | file_num: file_num + 1, path: path(dir, filename, date, file_num + 1)} _ -> state end end defp rotate(state), do: state defp delete_old_files(dir, filename, file_num, date, keep) do Enum.flat_map((file_num - keep)..0//-1, fn x -> path = path(dir, filename, date, x) case File.rm(path) do :ok -> [x] _ -> [] end end) end defp delete_files_for_date(dir, filename, date) do case File.ls(dir) do {:ok, files} -> files |> Enum.filter(&String.match?(&1, ~r/#{filename}_#{Date.to_string(date)}(\.\d+)?\.log/)) |> Enum.map(&File.rm(Path.join(dir, &1))) |> Enum.count() |> case do 0 -> false _ -> delete_files_for_date(dir, filename, Date.add(date, -1)) end _ -> false end end defp open_log(path) do case path |> Path.dirname() |> File.mkdir_p() do :ok -> case File.open(path, [:append, :utf8]) do {:ok, io_device} -> {:ok, io_device} other -> other end other -> other end end defp path(dir, filename, date, file_num) when is_nil(dir) or is_nil(filename) or is_nil(date) or is_nil(file_num) do nil end defp path(dir, filename, date, file_num) do "#{Path.join(dir, filename)}_#{Date.to_string(date)}.#{file_num}.log" end defp format_event(level, msg, ts, md, %{format: format, metadata: keys}) do Logger.Formatter.format(format, level, msg, ts, take_metadata(md, keys)) end @doc false @spec metadata_matches?(Keyword.t(), nil | Keyword.t()) :: true | false def metadata_matches?(_md, nil), do: true # all of the filter keys are present def metadata_matches?(_md, []), do: true def metadata_matches?(md, [{key, [_ | _] = val} | rest]) do case Keyword.fetch(md, key) do {:ok, md_val} -> md_val in val && metadata_matches?(md, rest) # fail on first mismatch _ -> false end end def metadata_matches?(md, [{key, val} | rest]) do case Keyword.fetch(md, key) do {:ok, ^val} -> metadata_matches?(md, rest) # fail on first mismatch _ -> false end end defp take_metadata(metadata, :all), do: metadata defp take_metadata(metadata, keys) do metadatas = Enum.reduce(keys, [], fn key, acc -> case Keyword.fetch(metadata, key) do {:ok, val} -> [{key, val} | acc] :error -> acc end end) Enum.reverse(metadatas) end defp configure(name, opts) do state = %{ name: nil, dir: nil, path: nil, filename: nil, io_device: nil, format: nil, level: nil, metadata: nil, metadata_filter: nil, rotate: nil, file_num: nil, date: nil } configure(name, opts, state) end defp configure(name, opts, state) do env = Application.get_env(:logger, name, []) opts = Keyword.merge(env, opts) Application.put_env(:logger, name, opts) level = Keyword.get(opts, :level) metadata = Keyword.get(opts, :metadata, []) format_opts = Keyword.get(opts, :format, @default_format) format = Logger.Formatter.compile(format_opts) dir = Keyword.get(opts, :dir, ".") |> parse_dir() filename = Keyword.get(opts, :filename, to_string(name)) metadata_filter = Keyword.get(opts, :metadata_filter) rotate = Keyword.get(opts, :rotate) date = local_date() file_num = current_file_num(dir, filename, date) path = path(dir, filename, date, file_num) if !state[:rotate][:daily] && rotate[:daily], do: send_date_rotate(date) %{ state | name: name, dir: dir, path: path, filename: filename, format: format, level: level, metadata: metadata, metadata_filter: metadata_filter, rotate: rotate, file_num: file_num, date: date } end defp parse_dir({path_type, app, opts}) when path_type in [:user_data, :user_log] do opts = opts |> Map.new() |> Map.take([:author, :version]) :filename.basedir(path_type, app, opts) |> to_string() end defp parse_dir(dir), do: dir defp local do DateTime.add(DateTime.utc_now(), -tz_offset()) end defp local_date do DateTime.to_date(local()) end defp tz_offset do case :os.type() do {:win32, :nt} -> {result, 0} = System.cmd("w32tm", ["/tz"]) [bias] = Regex.run( ~r/Bias: (.*)min \(UTC=LocalTime\+Bias\)/, result, capture: :all_but_first ) String.to_integer(bias) * 60 _ -> raise "OS not supported!" end end defp current_file_num(nil, _filename, _date), do: 0 defp current_file_num(dir, filename, date) do case File.ls(dir) do {:ok, files} -> date_str = Date.to_string(date) files |> Enum.flat_map(fn f -> case Regex.run(~r/#{filename}_#{date_str}\.(\d+)\.log/, f, capture: :all_but_first) do [n] -> [String.to_integer(n)] nil -> [] end end) |> Enum.sort(:desc) |> Enum.take(1) |> case do [n] -> n [] -> 0 end {:error, _} -> 0 end end @replacement "�" @spec prune(IO.chardata()) :: IO.chardata() def prune(binary) when is_binary(binary), do: prune_binary(binary, "") def prune([h | t]) when h in 0..1_114_111, do: [h | prune(t)] def prune([h | t]), do: [prune(h) | prune(t)] def prune([]), do: [] def prune(_), do: @replacement defp prune_binary(<>, acc), do: prune_binary(t, <>) defp prune_binary(<<_, t::binary>>, acc), do: prune_binary(t, <>) defp prune_binary(<<>>, acc), do: acc end