lib/salemove/http_client/middleware/logger.ex

defmodule Salemove.HttpClient.Middleware.Logger do
  @behaviour Tesla.Middleware

  @moduledoc """
  Log requests as single line.

  Logs request method, url, response status and time taken in milliseconds.

  ### Example usage
  ```
  defmodule MyClient do
    use Tesla

    plug Salemove.HttpClient.Middleware.Logger,
      level: %{
        100..399 => :info,
        422 => :info,
        400..499 => :warning,
        500..600 => :error
      }
  end
  ```

  ### Logger output
  ```
  2017-09-30 13:39:06.663 [info] GET http://example.com -> 200 (736.988 ms)
  ```
  """

  require Logger

  def call(env, next, opts) do
    time_start = System.monotonic_time()

    with {:ok, env} <- Tesla.run(env, next) do
      elapsed_ms = elapsed_ms(time_start)

      log(env, elapsed_ms, metadata(env, elapsed_ms), opts)
      {:ok, env}
    else
      {:error, error} ->
        elapsed_ms = elapsed_ms(time_start)

        log(env, error, elapsed_ms, metadata(env, elapsed_ms), opts)
        {:error, error}
    end
  end

  defp log(env, :timeout, elapsed_ms, metadata, _opts) do
    message = "#{normalize_method(env)} #{env.url} -> :timeout (#{elapsed_ms} ms)"
    Logger.log(:warning, message, metadata ++ [status: "timeout"])
  end

  defp log(env, :closed, elapsed_ms, metadata, _opts) do
    message = "#{normalize_method(env)} #{env.url} -> :closed (#{elapsed_ms} ms)"
    Logger.log(:warning, message, metadata ++ [status: "closed"])
  end

  defp log(env, %Tesla.Error{reason: reason}, elapsed_ms, metadata, opts) do
    status = inspect(reason)

    log_status(
      0,
      "#{normalize_method(env)} #{env.url} -> #{status} (#{elapsed_ms} ms)",
      metadata ++ [status: normalize_status(status)],
      opts
    )
  end

  defp log(env, error, elapsed_ms, metadata, opts) do
    status = inspect(error)

    log_status(
      0,
      "#{normalize_method(env)} #{env.url} -> #{status} (#{elapsed_ms} ms)",
      metadata ++ [status: normalize_status(status)],
      opts
    )
  end

  defp log(env, elapsed_ms, metadata, opts) do
    message = "#{normalize_method(env)} #{env.url} -> #{env.status} (#{elapsed_ms} ms)"

    log_status(env.status, message, metadata ++ [status: normalize_status(env.status)], opts)
  end

  defp normalize_method(env) do
    env.method |> to_string() |> String.upcase()
  end

  defp normalize_status(status), do: to_string(status)

  defp extract_path(%{url: url} = _env) do
    %URI{path: path} = URI.parse(url)
    path
  end

  defp elapsed_ms(from) do
    now = System.monotonic_time()
    us = System.convert_time_unit(now - from, :native, :microsecond)
    :io_lib.format("~.3f", [us / 1000])
  end

  defp log_status(status, message, metadata, opts) do
    levels = Keyword.get(opts || [], :level)

    status
    |> status_to_level(levels)
    |> Logger.log(message, metadata)
  end

  defp status_to_level(status, levels) when is_map(levels) do
    case levels do
      %{^status => level} -> level
      levels -> find_matching_level(levels, status) || status_to_level(status, nil)
    end
  end

  defp status_to_level(status, _) do
    cond do
      status >= 500 -> :warning
      status >= 400 || status == 0 -> :error
      status >= 300 -> :warning
      true -> :info
    end
  end

  defp find_matching_level(levels, status) do
    Enum.find_value(levels, fn
      {%Range{} = range, level} -> if status in range, do: level
      {^status, level} -> level
      _ -> false
    end)
  end

  defp metadata(env, elapsed_ms) do
    [method: normalize_method(env), path: extract_path(env), duration_ms: to_string(elapsed_ms), url: env.url]
  end
end