Ctrl K
ComunidadGuías…

lib/audio_proxy/log_handler.ex

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
defmodule AudioProxy.LogHandler do
  @moduledoc """
  Turns telemetry into the log an operator reads: one line per request, plus
  the render lifecycle behind it.

  Attached once from `AudioProxy.Application.start/2`. Nothing else in the
  tree calls `Logger` for request outcomes — a plug that wants its verdict
  logged says so in `conn.assigns`, and the render action says so by emitting
  `AudioProxy.Telemetry` events.

  ## The request line

  Driven by Bandit's own `[:bandit, :request, :stop]`, which is one event per
  request with a real monotonic duration and the byte count as sent on the
  wire. `Plug.Logger` would cost two lines per request and could not see
  either. What the line says comes from `conn.assigns`, stashed upstream:

      render 200 opts=br:96/f:opus src=local://piece.wav cache=MISS 18 bytes in 3.2ms
      render 200 opts=f:mp3 src=local://long.wav cache=COALESCED 9600567 bytes in 3.1ms
      render 422 invalid_options 61 bytes in 0.4ms
      health 200 45 bytes in 0.1ms

  The endpoint class leads (`AudioProxy.Router` assigns it), the error class
  follows the status when a request failed (`AudioProxy.ErrorJSON` assigns
  it), and the normalized options string, canonical source and coalescing
  status appear once the chain has got far enough to know them — a 401 knows
  none of them, and says so by omission rather than with placeholders. The
  second line above is why `cache=` is worth a column: it is a request that
  attached to a render already in flight, and without the field its duration
  reads as an impossibly fast encode. The request id is not in the line:
  `Plug.RequestId` puts it in Logger metadata, which the formatter renders on
  every line including the render lifecycle's, so correlation works across
  all of them rather than only this one.

  ## Levels

  2xx–4xx at info: a client error is a normal outcome for a public endpoint
  and an operator paging on 401s would learn to ignore the log. 5xx and
  timeouts at warning — those are the proxy's fault. `/health`, `/ready` and
  `/metrics` at debug, filtered here by endpoint class: probes every second
  and a scrape every fifteen would otherwise be the entire log, and splitting
  the router to avoid it would buy the same silence for more moving parts.
  That covers `/ready`'s 503 too — a node declining work is the mechanism
  doing its job, not an incident.

  `AP_LOG_LEVEL` sets the floor (`AudioProxy.Config`), so `warning` silences
  the happy path wholesale.

  ## What is deliberately not attached

  `[:bandit, :request, :exception]` — the mid-stream abort case. The render
  action has already emitted `[:audio_proxy, :render, :exception]` with the
  failure class, format and source by the time it exits, and Bandit logs the
  exit itself; a third line for one event would be noise. The cost is that a
  plug crash unrelated to a render produces Bandit's error report and no
  request line, which is a bug report either way.

  ## A handler that raises is a handler that stops existing

  `:telemetry` detaches a handler that raises, permanently and silently — so
  one malformed event does not lose one line, it loses *every* line for the
  life of the VM, and nothing says so. That makes total-ness the property
  this module cares about most, and it is defended twice.

  Nothing here reads a field it has not proved is there: statuses that are
  not integers render as `-`, durations that are not integers as `?ms`,
  absent metadata falls back rather than raising. `Integer.to_string/1` on a
  status was the concrete bug — Bandit emits `[:bandit, :request, :stop]`
  with a conn but no status when a request dies of a protocol or transport
  error (`Bandit.Pipeline`'s error path), and `conn.status >= 500` did not
  even fail loudly on it: in Elixir's term order an atom sorts above every
  number, so `nil >= 500` is `true` and a status-less request was already
  being levelled as a server error.

  Behind that, `handle_event/4` rescues anything it did not anticipate and
  logs a terse line about itself instead of dying. Swallowing an exception is
  normally the wrong instinct; here the alternative is losing the log
  wholesale, and the rescue still says what happened.

  ## Redaction

  `detail` on a render exception is the one field carrying text this
  application did not construct — an ffmpeg stderr tail. Today's inputs are
  local paths, but the S3 backend hands ffmpeg presigned URLs, and a
  diagnostic that echoed one would put a live `X-Amz-Signature` into log
  storage. `redact/1` runs over every tail before it is logged, so the
  guarantee holds by construction rather than by an assumption about what
  ffmpeg prints. Everything else in a line is built from
  `AudioProxy.Telemetry`'s metadata, which carries the canonical source and
  never the ffmpeg input.
  """

  require Logger

  alias AudioProxy.{Options, Telemetry}

  @handler_id __MODULE__

  @bandit_stop [:bandit, :request, :stop]

  # A URL's query string, and bare credential parameters outside one. The
  # marker replaces the parameter *name* too: a line reading
  # "X-Amz-Signature=[redacted]" still tells a reader which credential was in
  # play, and the guarantee is that no credential material appears at all.
  #
  # The first pattern is what actually covers a presigned URL, since an ffmpeg
  # input always has a scheme. The second is for credential parameters that
  # reach a diagnostic detached from their URL, and lists the three signing
  # schemes a proxy is plausibly pointed at — S3, GCS, and Azure's `sig`.
  # Anything it does not know is a name to add here, not a design to revisit.
  #
  # Both are backtracking patterns over untrusted text, which is safe only
  # because the text is bounded: `AudioProxy.Ffmpeg.Render` caps its stderr
  # tail at 4 KB. That cap is load-bearing, not incidental.
  @url_query ~r{(://\S*?)\?\S*}
  @credential_param ~r/\b(?:X-Amz-[\w-]+|X-Goog-[\w-]+|AWSAccessKeyId|Signature|Expires|sig)=[^\s&]*/i

  @redacted "[redacted]"

  @doc """
  Attaches the handler. Idempotent — a second call is a no-op.
  """
  @spec attach() :: :ok
  def attach do
    events = [@bandit_stop, Telemetry.store_write_failure_event() | Telemetry.render_events()]

    case :telemetry.attach_many(@handler_id, events, &__MODULE__.handle_event/4, nil) do
      :ok -> :ok
      {:error, :already_exists} -> :ok
    end
  end

  @doc "Detaches the handler. For tests that need the log quiet."
  @spec detach() :: :ok
  def detach do
    _ = :telemetry.detach(@handler_id)
    :ok
  end

  @doc """
  Strips credential material from a diagnostic tail.

  Query strings are removed from anything URL-shaped, and credential
  parameters are removed wherever they appear.

      iex> AudioProxy.LogHandler.redact("open https://b.s3.amazonaws.com/k.wav?X-Amz-Signature=deadbeef failed")
      "open https://b.s3.amazonaws.com/k.wav?[redacted] failed"
  """
  @spec redact(String.t()) :: String.t()
  def redact(text) when is_binary(text) do
    text
    |> String.replace(@url_query, "\\1?#{@redacted}")
    |> String.replace(@credential_param, @redacted)
  end

  @doc false
  @spec handle_event(:telemetry.event_name(), map(), map(), term()) :: :ok
  def handle_event(event, measurements, metadata, config) do
    emit(event, measurements, metadata, config)
    :ok
  rescue
    exception ->
      # See the moduledoc: dying here would take every future line with it.
      Logger.error("log handler failed on #{inspect(event)}: #{Exception.message(exception)}")
      :ok
  catch
    kind, reason ->
      Logger.error("log handler failed on #{inspect(event)}: #{kind} #{inspect(reason)}")
      :ok
  end

  defp emit(@bandit_stop, measurements, %{conn: conn} = metadata, _config) do
    log(request_level(conn), fn -> request_line(conn, measurements, metadata) end)
  end

  # Bandit omits `conn` when it could not build one — a malformed request line,
  # a TLS handshake that never became HTTP. There is no request to describe.
  defp emit(@bandit_stop, _measurements, _metadata, _config), do: :ok

  defp emit([:audio_proxy, :render, :start], _measurements, metadata, _config) do
    log(:debug, fn -> "render start #{field(metadata, :format)} #{field(metadata, :source)}" end)
  end

  defp emit([:audio_proxy, :render, :stop], measurements, metadata, _config) do
    log(:debug, fn ->
      "render #{field(metadata, :outcome)} #{field(metadata, :format)} " <>
        "#{field(metadata, :source)} #{bytes(measurements, :bytes)} bytes " <>
        "in #{ms(measurements[:duration])}"
    end)
  end

  defp emit([:audio_proxy, :render, :exception], measurements, metadata, _config) do
    log(:warning, fn ->
      "render failed (#{field(metadata, :class)}, exit #{inspect(metadata[:exit_status])}) " <>
        "#{field(metadata, :format)} #{field(metadata, :source)} " <>
        "after #{bytes(measurements, :bytes)} bytes " <>
        "in #{ms(measurements[:duration])}#{detail(metadata[:detail])}"
    end)
  end

  # Warning, not error: clients were served and the render succeeded — but
  # the cache silently stopped filling, which an operator watching only
  # request lines would never see. `reason` is inspected text this module did
  # not build (an exception message can embed anything), so it is redacted
  # like a diagnostic tail.
  defp emit([:audio_proxy, :variant_store, :write_failure], _measurements, metadata, _config) do
    log(:warning, fn ->
      "variant store write failed for #{field(metadata, :key)}: " <>
        redact(inspect(metadata[:reason]))
    end)
  end

  ## The line

  defp request_line(conn, measurements, metadata) do
    [
      class(conn),
      status(conn),
      error_class(conn),
      options(conn),
      source(conn),
      cache_status(conn),
      transport_error(metadata),
      "#{bytes(measurements, :resp_body_bytes)} bytes",
      "in #{ms(measurements[:duration])}"
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.join(" ")
  end

  defp class(conn), do: conn.assigns |> Map.get(:endpoint_class, :unknown) |> to_string()

  # `-` rather than a number: the response never got far enough to have one.
  defp status(%{status: status}) when is_integer(status), do: Integer.to_string(status)
  defp status(_conn), do: "-"

  defp error_class(conn) do
    with class when not is_nil(class) <- conn.assigns[:error_class], do: to_string(class)
  end

  defp options(conn) do
    with %Options{} = opts <- conn.assigns[:options], do: "opts=" <> Options.normalize(opts)
  end

  defp source(conn) do
    with source when not is_nil(source) <- conn.assigns[:source] do
      "src=" <> AudioProxy.Source.canonical(source)
    end
  end

  # Without this a coalesced request is actively misleading rather than merely
  # terse: it delivers the whole variant out of a running render's backlog, so
  # the line reports megabytes in a few milliseconds and reads as an
  # impossibly fast encode. Absent before the action subscribes, and so absent
  # from every line that never got that far.
  defp cache_status(conn) do
    with status when not is_nil(status) <- conn.assigns[:render_status] do
      "cache=" <> (status |> to_string() |> String.upcase())
    end
  end

  # Bandit puts its own message here when the span ended in a protocol or
  # transport failure — the only account of what happened to a request that
  # never got a status.
  defp transport_error(metadata) do
    with error when is_binary(error) <- metadata[:error], do: "error=" <> redact(error)
  end

  # A render that dies after its 200 never reaches `[:bandit, :request, :stop]`
  # at all, so a status that *is* there is always one the chain chose. One that
  # is not means the request died below the plug, which is never routine.
  defp request_level(conn) do
    cond do
      not is_integer(conn.status) -> :warning
      # Both probes are polled every few seconds per node; at info they would
      # be most of the log. A `/ready` 503 is deliberately not promoted past
      # this — it is the endpoint working, not the node in trouble.
      conn.assigns[:endpoint_class] in [:health, :ready, :metrics] -> :debug
      conn.status >= 500 -> :warning
      true -> :info
    end
  end

  defp detail(text) when is_binary(text) and text != "" do
    ": " <> (text |> String.trim() |> redact())
  end

  defp detail(_absent), do: ""

  defp bytes(measurements, key) do
    case measurements[key] do
      count when is_integer(count) and count >= 0 -> count
      _absent -> 0
    end
  end

  defp field(metadata, key), do: metadata |> Map.get(key, "-") |> to_string()

  # Native units are meaningless to a reader and their scale is
  # platform-dependent; one decimal place of milliseconds is what a duration
  # is compared against.
  defp ms(native) when is_integer(native) do
    micro = System.convert_time_unit(native, :native, :microsecond)

    :erlang.float_to_binary(micro / 1_000, decimals: 1) <> "ms"
  end

  defp ms(_absent), do: "?ms"

  # A handler runs in the process that emitted the event, so a `Logger` call
  # here is the same call the emitting code would have made — level filtering
  # included, which is what keeps a debug line from being built at info.
  defp log(level, message_fun), do: Logger.log(level, message_fun)
end