core/gear_log/file_handle.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule AntikytheraCore.GearLog.FileHandle do
  alias Antikythera.{Time, ContextId}
  alias AntikytheraCore.GearLog.{Level, Message}

  defmodule SizeCheck do
    @interval if Antikythera.Env.compiling_for_release?() ||
                   Antikythera.Env.compiling_for_mix_task?(),
                 do: 30_000,
                 else: 100
    # 100MB
    @max_size if Mix.env() == :test, do: 4_096, else: 104_857_600

    defun check_now?(now :: v[Time.t()], last_checked_at :: v[Time.t()]) :: boolean do
      @interval <= Time.diff_milliseconds(now, last_checked_at)
    end

    defun exceeds_limit?(file_path :: Path.t()) :: boolean do
      %File.Stat{size: size} = File.stat!(file_path)
      @max_size < size
    end
  end

  @opaque t :: {Path.t(), Time.t(), File.io_device(), boolean}

  defun open(file_path :: Path.t(), opts :: Keyword.t() \\ []) :: t do
    write_to_terminal? = Keyword.get(opts, :write_to_terminal, determine_write_to_terminal())
    :ok = File.mkdir_p(Path.dirname(file_path))

    if File.exists?(file_path) do
      rename(file_path)
    end

    {file_path, Time.now(), open_file(file_path), write_to_terminal?}
  end

  defun write(
          {file_path, last_checked_at, io_device, write_to_terminal?} = handle :: t,
          {now, _, _, _} = gear_log :: Message.t()
        ) :: {:kept_open | :rotated, t} do
    if SizeCheck.check_now?(now, last_checked_at) do
      if SizeCheck.exceeds_limit?(file_path) do
        {_, _, new_io_device, _} = new_handle = rotate(handle)
        do_write(new_io_device, gear_log, write_to_terminal?)
        {:rotated, new_handle}
      else
        do_write(io_device, gear_log, write_to_terminal?)
        {:kept_open, {file_path, now, io_device, write_to_terminal?}}
      end
    else
      do_write(io_device, gear_log, write_to_terminal?)
      {:kept_open, handle}
    end
  end

  defunp do_write(
           io_device :: :file.io_device(),
           {time, level, context_id, msg} :: Message.t(),
           write_to_terminal? :: boolean
         ) :: :ok do
    prefix = log_prefix(time, level, context_id)

    formatted_lines_str =
      String.split(msg, "\n", trim: true)
      |> Enum.reduce("", fn s, acc -> acc <> prefix <> s <> "\n" end)

    :ok = IO.binwrite(io_device, formatted_lines_str)
    if write_to_terminal?, do: write_debug_log(level, formatted_lines_str), else: :ok
  end

  defunp log_prefix(time :: v[Time.t()], level :: v[Level.t()], context_id :: v[ContextId.t()]) ::
           String.t() do
    Time.to_iso_timestamp(time) <>
      " [" <> Atom.to_string(level) <> "] context=" <> context_id <> " "
  end

  defun set_write_to_terminal(
          {file_path, last_checked_at, io_device, _} = _handle :: t,
          new_val :: boolean
        ) :: t do
    {file_path, last_checked_at, io_device, new_val}
  end

  defun restore_write_to_terminal({file_path, last_checked_at, io_device, _} = _handle :: t) :: t do
    {file_path, last_checked_at, io_device, determine_write_to_terminal()}
  end

  defun rotate({file_path, _, io_device, write_to_terminal?} :: t) :: t do
    :ok = File.close(io_device)
    rename(file_path)
    {file_path, Time.now(), open_file(file_path), write_to_terminal?}
  end

  defunp open_file(file_path :: Path.t()) :: File.io_device() do
    File.open!(file_path, [:write, :compressed])
  end

  defun close({_, _, io_device, _} :: t) :: :ok do
    :ok = File.close(io_device)
  end

  defunp rename(file_path :: Path.t()) :: :ok do
    :ok = File.rename(file_path, rotated_file_path(file_path))
  end

  defunp rotated_file_path(base_file_path :: Path.t()) :: Path.t() do
    import Antikythera.StringFormat
    {Time, {y, mon, d}, {h, minute, s}, _ms} = Time.now()
    now_str_with_ext = "#{y}#{pad2(mon)}#{pad2(d)}#{pad2(h)}#{pad2(minute)}#{pad2(s)}.gz"
    String.replace(base_file_path, ~R/gz\z/, now_str_with_ext)
  end

  defunp write_debug_log(level :: v[Level.t()], formatted :: v[String.t()]) :: :ok do
    colored =
      case level do
        :error -> IO.ANSI.red() <> formatted <> IO.ANSI.reset()
        :debug -> IO.ANSI.cyan() <> formatted <> IO.ANSI.reset()
        _ -> formatted
      end

    if String.valid?(colored) do
      IO.write(colored)
    else
      IO.binwrite(colored)
    end
  end

  defp determine_write_to_terminal() do
    !Antikythera.Env.compiling_for_release?() &&
      (Antikythera.Env.compiling_for_mix_task?() ||
         Mix.env() == :dev ||
         (Mix.env() == :test && System.get_env("TEST_LOG_ON_TERMINAL") == "true"))
  end
end