lib/appsignal_plug.ex

defmodule Appsignal.Plug do
  @span Application.get_env(:appsignal, :appsignal_span, Appsignal.Span)
  import Appsignal.Utils, only: [module_name: 1]

  @moduledoc """
  AppSignal's Plug instrumentation instruments calls to Plug applications to
  gain performance insights and error reporting.

  ## Installation

  To install `Appsignal.Plug` into your Plug application, `use Appsignal.Plug`
  in your application's router module:

      defmodule AppsignalPlugExample do
        use Plug.Router
        use Appsignal.Plug

        plug(:match)
        plug(:dispatch)

        get "/" do
          send_resp(conn, 200, "Welcome")
        end
      end
  """

  @doc false
  defmacro __using__(_) do
    quote do
      require Logger
      Appsignal.Logger.debug("AppSignal.Plug attached to #{__MODULE__}")

      @tracer Application.get_env(:appsignal, :appsignal_tracer, Appsignal.Tracer)
      @span Application.get_env(:appsignal, :appsignal_span, Appsignal.Span)

      use Plug.ErrorHandler

      def call(%Plug.Conn{private: %{appsignal_plug_instrumented: true}} = conn, opts) do
        Logger.warn(
          "Appsignal.Plug was included twice, disabling Appsignal.Plug. Please only `use Appsignal.Plug` once."
        )

        super(conn, opts)
      end

      def call(conn, opts) do
        Appsignal.instrument(fn span ->
          _ = @span.set_namespace(span, "http_request")

          try do
            super(conn, opts)
          catch
            kind, reason ->
              stack = __STACKTRACE__

              _ =
                span
                |> Appsignal.Plug.handle_error(kind, reason, stack, conn)
                |> @tracer.close_span()

              @tracer.ignore()
              :erlang.raise(kind, reason, stack)
          else
            conn ->
              _ = Appsignal.Plug.set_conn_data(span, conn)
              Plug.Conn.put_private(conn, :appsignal_plug_instrumented, true)
          end
        end)
      end

      defoverridable call: 2
    end
  end

  @doc """
  Adds an `:appsignal_name` to the `Plug.Conn`, to overwrite the root
  `Appsignal.Span`'s name.

  ## Examples

      iex> Appsignal.Plug.put_name(%Plug.Conn{}, "AppsignalPlugExample#index")
      %Plug.Conn{private: %{appsignal_name: "AppsignalPlugExample#index"}}

  In a Plug app, call `Appsignal.Plug.put_name/2` on the returned `Plug.Conn`
  struct:

      defmodule AppsignalPlugExample do
        use Plug.Router
        use Appsignal.Plug

        plug(:match)
        plug(:dispatch)

        get "/" do
          conn
          |> Appsignal.Plug.put_name("AppsignalPlugExample#index")
          |> send_resp(200, "Welcome")
        end
      end
  """
  def put_name(%Plug.Conn{} = conn, name) do
    Plug.Conn.put_private(conn, :appsignal_name, name)
  end

  @doc false
  def set_conn_data(span, conn) do
    span
    |> set_name(conn)
    |> set_category(conn)
    |> set_params(conn)
    |> set_sample_data(conn)
    |> set_session_data(conn)
  end

  @doc false
  def handle_error(
        span,
        :error,
        %Plug.Conn.WrapperError{conn: conn, reason: wrapped_reason, stack: stack},
        _stack,
        _conn
      ) do
    handle_error(span, :error, wrapped_reason, stack, conn)
  end

  @doc false
  def handle_error(span, _kind, %{plug_status: status}, _stack, _conn) when status < 500 do
    span
  end

  @doc false
  def handle_error(span, kind, reason, stack, conn) do
    conn_with_status = Plug.Conn.put_status(conn, Plug.Exception.status(reason))

    span
    |> @span.add_error(kind, reason, stack)
    |> set_conn_data(conn_with_status)
  end

  defp set_name(span, %Plug.Conn{private: %{appsignal_name: name}}) do
    @span.set_name(span, name)
  end

  defp set_name(span, %Plug.Conn{
         private: %{phoenix_action: action, phoenix_controller: controller}
       }) do
    @span.set_name(span, "#{module_name(controller)}##{action}")
  end

  defp set_name(span, %Plug.Conn{method: method, private: %{plug_route: {path, _fun}}}) do
    @span.set_name(span, "#{method} #{path}")
  end

  defp set_name(span, _conn) do
    span
  end

  defp set_category(span, %Plug.Conn{private: %{phoenix_endpoint: _endpoint}}) do
    @span.set_attribute(span, "appsignal:category", "call.phoenix")
  end

  defp set_category(span, _conn) do
    @span.set_attribute(span, "appsignal:category", "call.plug")
  end

  defp set_params(span, conn) do
    set_params(span, Application.get_env(:appsignal, :config), conn)
  end

  defp set_params(span, %{send_params: true}, conn) do
    %Plug.Conn{params: params} = Plug.Conn.fetch_query_params(conn)
    @span.set_sample_data(span, "params", params)
  end

  defp set_params(span, _config, _conn) do
    span
  end

  defp set_sample_data(span, conn) do
    @span.set_sample_data(span, "environment", Appsignal.Metadata.metadata(conn))
  end

  defp set_session_data(span, conn) do
    set_session_data(span, Application.get_env(:appsignal, :config), conn)
  end

  defp set_session_data(span, %{send_session_data: true}, %Plug.Conn{
         private: %{plug_session: session, plug_session_fetch: :done}
       }) do
    @span.set_sample_data(span, "session_data", session)
  end

  defp set_session_data(span, _config, _conn) do
    span
  end
end