defmodule MoundHuntersWeb.Plugs.RequestLogger do @moduledoc """ Logs each HTTP request as a single JSON line in combined log format plus custom fields. Log format includes: - Standard combined log fields: remote_ip, timestamp, method, path, status, bytes, referer, user_agent - Custom fields: request_id, duration_ms, query_params, lat, lng, tile_id, lookup_id, error """ import Plug.Conn require Logger def init(opts), do: opts def call(conn, _opts) do start_time = System.monotonic_time(:microsecond) request_id = generate_request_id() conn = conn |> put_private(:request_start_time, start_time) |> put_private(:request_id, request_id) register_before_send(conn, fn conn -> log_request(conn, start_time, request_id) conn end) end defp log_request(conn, start_time, request_id) do end_time = System.monotonic_time(:microsecond) duration_ms = (end_time - start_time) / 1000.0 log_entry = %{ # Standard combined log format fields remote_ip: format_remote_ip(conn.remote_ip), timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), method: conn.method, path: conn.request_path, query_string: conn.query_string, status: conn.status, bytes_sent: get_resp_header(conn, "content-length") |> List.first() || "-", referer: get_req_header(conn, "referer") |> List.first() || "-", user_agent: get_req_header(conn, "user-agent") |> List.first() || "-", # Custom fields request_id: request_id, duration_ms: Float.round(duration_ms, 2), # Tile-specific fields (if present) lat: get_query_param(conn, "lat"), lng: get_query_param(conn, "lng"), tile_id: get_private_field(conn, :tile_id), lookup_id: get_private_field(conn, :lookup_id), # Error information error: get_private_field(conn, :error_message) } # Remove nil values for cleaner logs log_entry = Enum.reject(log_entry, fn {_k, v} -> is_nil(v) end) |> Map.new() # Log to file as JSON line json_line = Jason.encode!(log_entry) Logger.info(json_line, logger: :request_log) # Also log summary to console console_msg = "#{conn.method} #{conn.request_path} - #{conn.status} - #{Float.round(duration_ms, 2)}ms" Logger.info(console_msg) end defp format_remote_ip({a, b, c, d}) do "#{a}.#{b}.#{c}.#{d}" end defp format_remote_ip({a, b, c, d, e, f, g, h}) do parts = [a, b, c, d, e, f, g, h] parts |> Enum.map(&Integer.to_string(&1, 16)) |> Enum.join(":") end defp get_query_param(conn, key) do case conn.query_params do %{^key => value} when value != "" -> value _ -> nil end end defp get_private_field(conn, key) do case conn.private do %{^key => value} -> value _ -> nil end end defp generate_request_id do :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false) end end