lib/prom_ex/plugins/phoenix.ex

if Code.ensure_loaded?(Phoenix) do
  defmodule PromEx.Plugins.Phoenix do
    @moduledoc """
    This plugin captures metrics emitted by Phoenix. Specifically, it captures HTTP request metrics and
    Phoenix channel metrics.

    ## Plugin options

    This plugin supports the following options:
    - `metric_prefix`: This option is OPTIONAL and is used to override the default metric prefix of
      `[otp_app, :prom_ex, :phoenix]`. If this changes you will also want to set `phoenix_metric_prefix`
      in your `dashboard_assigns` to the snakecase version of your prefix, the default
      `phoenix_metric_prefix` is `{otp_app}_prom_ex_phoenix`.

    ### Single Endpoint/Router
    - `router`: This option is REQUIRED and is the full module name of your Phoenix Router (e.g MyAppWeb.Router).

    - `endpoint`: This is a REQUIRED option and is the full module name of your Phoenix Endpoint (e.g MyAppWeb.Endpoint).

    - `event_prefix`: This option is OPTIONAL and allows you to set the event prefix for the Telemetry events. This
      value should align with what you pass to `Plug.Telemetry` in your `endpoint.ex` file (see the plug docs
      for more information https://hexdocs.pm/plug/Plug.Telemetry.html) This value should align with what you pass
      to `Plug.Telemetry` in your `endpoint.ex` file (see the plug docs for more
      information https://hexdocs.pm/plug/Plug.Telemetry.html)

    - `additional_routes`: This option is OPTIONAL and allows you to specify route path labels for applications routes
      not defined in your Router module.

      For example, if you want to track telemetry events for a plug in your
      `endpoint.ex` file, you can provide a keyword list with the structure `[some-route: ~r(\/some-path)]` and any
      time that the route is called and the plug handles the call, the path label for this particular Prometheus metric
      will be set to `some-route`. You can pass in either a regular expression or a string to match the incoming
      request.

    #### Example plugin configuration

    ```elixir
    {
      PromEx.Plugins.Phoenix,
      endpoint: MyApp.Endpoint,
      router: MyAppWeb.Public.Router,
      event_prefix: [:admin, :endpoint]
    }
    ```

    ### Multiple Endpoints/Router

    - `endpoints`: This accepts a list of per Phoenix Endpoint options `{endpoint_name, endpoint_opts}`
      - `endpoint_name`: This option is REQUIRED and is the full module name of your Phoenix Endpoint (e.g MyAppWeb.Endpoint).

      - `endpoint_opts`: Per endpoint plugin options:
        - `:routers`: This option is REQUIRED and lists all of routers modules for the endpoint, the HTTP metrics will
          be augmented with controller/action/path information from the routers.

        - `:event_prefix`: This option is OPTIONAL and allows you to set the event prefix for the Telemetry events. This
        value should align with what you pass to `Plug.Telemetry` in the  corresponding endpoint module (see the plug docs
        for more information https://hexdocs.pm/plug/Plug.Telemetry.html)

        - `:additional_routes`: This option is OPTIONAL and allows you to specify route path labels for applications routes
        not defined in your Router modules for the corresponding endpoint.

    #### Example plugin configuration

    ```elixir
    {
      PromEx.Plugins.Phoenix,
      endpoints: [
        {MyApp.Endpoint, routers: [MyAppWeb.Public.Router]},
        {MyApp.Endpoint2, routers: [MyAppWeb.Admin.Router], event_prefix: [:admin, :endpoint]}
      ]
    }
    ```

    ## Metric Groups

    This plugin exposes the following metric groups:
    - `:phoenix_http_event_metrics`
    - `:phoenix_channel_event_metrics`
    - `:phoenix_socket_event_metrics`
    - `:phoenix_endpoint_manual_metrics`

    ## Usage

    To use plugin in your application, add the following to your PromEx module:

    ```elixir
    defmodule WebApp.PromEx do
      use PromEx, otp_app: :web_app

      @impl true
      def plugins do
        [
          ...
          {
            PromEx.Plugins.Phoenix,
            endpoint: MyApp.Endpoint,
            router: MyAppWeb.Public.Router
          }
        ]
      end

      @impl true
      def dashboards do
        [
          ...
          {:prom_ex, "phoenix.json"}
        ]
      end
    end
    ```

    When working with multiple Phoenix routers use the `endpoints` option instead:

    ```elixir
    defmodule WebApp.PromEx do
      use PromEx, otp_app: :web_app

      @impl true
      def plugins do
        [
          ...
          {
            PromEx.Plugins.Phoenix,
            endpoints: [
              {MyApp.Endpoint, routers: [MyAppWeb.Public.Router]},
              {MyApp.Endpoint2, routers: [MyAppWeb.Admin.Router], event_prefix: [:admin, :endpoint]}
            ]
          }
        ]
      end

      @impl true
      def dashboards do
        [
          ...
          {:prom_ex, "phoenix.json"}
        ]
      end
    end
    ```
    """

    use PromEx.Plugin

    require Logger

    alias Phoenix.Socket
    alias Plug.Conn

    @stop_event [:prom_ex, :plugin, :phoenix, :stop]

    @impl true
    def event_metrics(opts) do
      otp_app = Keyword.fetch!(opts, :otp_app)
      metric_prefix = Keyword.get(opts, :metric_prefix, PromEx.metric_prefix(otp_app, :phoenix))
      phoenix_event_prefixes = fetch_event_prefixes!(opts)

      set_up_telemetry_proxy(phoenix_event_prefixes)

      # Event metrics definitions
      [
        http_events(metric_prefix, opts),
        channel_events(metric_prefix),
        socket_events(metric_prefix)
      ]
    end

    @impl true
    def manual_metrics(opts) do
      otp_app = Keyword.fetch!(opts, :otp_app)
      metric_prefix = PromEx.metric_prefix(otp_app, :phoenix)

      [
        endpoint_info(metric_prefix, opts)
      ]
    end

    defp endpoint_info(metric_prefix, opts) do
      # Fetch user options
      phoenix_endpoint = Keyword.get(opts, :endpoint) || Keyword.get(opts, :endpoints)

      Manual.build(
        :phoenix_endpoint_manual_metrics,
        {__MODULE__, :execute_phoenix_endpoint_info, [phoenix_endpoint]},
        [
          last_value(
            metric_prefix ++ [:endpoint, :url, :info],
            event_name: [:prom_ex, :plugin, :phoenix, :endpoint_url],
            description: "The configured URL of the Endpoint module.",
            measurement: :status,
            tags: [:url, :endpoint]
          ),
          last_value(
            metric_prefix ++ [:endpoint, :port, :info],
            event_name: [:prom_ex, :plugin, :phoenix, :endpoint_port],
            description: "The configured port of the Endpoint module.",
            measurement: :status,
            tags: [:port, :endpoint]
          )
        ]
      )
    end

    @doc false
    def execute_phoenix_endpoint_info(endpoint) do
      # TODO: This is a bit of a hack until Phoenix supports an init telemetry event to
      # reliably get the configuration.
      endpoint_init_checker = fn
        count, endpoint_module, endpoint_init_checker_function when count < 10 ->
          case Process.whereis(endpoint_module) do
            pid when is_pid(pid) ->
              measurements = %{status: 1}
              url_metadata = %{url: endpoint_module.url(), endpoint: normalize_module_name(endpoint_module)}
              :telemetry.execute([:prom_ex, :plugin, :phoenix, :endpoint_url], measurements, url_metadata)

              %URI{port: port} = endpoint_module.struct_url()
              port_metadata = %{port: port, endpoint: normalize_module_name(endpoint_module)}
              :telemetry.execute([:prom_ex, :plugin, :phoenix, :endpoint_port], measurements, port_metadata)

            _ ->
              Process.sleep(1_000)
              endpoint_init_checker_function.(count + 1, endpoint_module, endpoint_init_checker_function)
          end

        _, _, _ ->
          :noop
      end

      if is_list(endpoint) do
        endpoint
        |> Enum.each(fn {endpoint_module, _} ->
          Task.start(fn ->
            endpoint_init_checker.(0, endpoint_module, endpoint_init_checker)
          end)
        end)
      else
        Task.start(fn ->
          endpoint_init_checker.(0, endpoint, endpoint_init_checker)
        end)
      end
    end

    defp http_events(metric_prefix, opts) do
      routers = fetch_routers!(opts)
      additional_routes = fetch_additional_routes!(opts)
      http_metrics_tags = [:status, :method, :path, :controller, :action]

      Event.build(
        :phoenix_http_event_metrics,
        [
          # Capture request duration information
          distribution(
            metric_prefix ++ [:http, :request, :duration, :milliseconds],
            event_name: @stop_event,
            measurement: :duration,
            description: "The time it takes for the application to respond to HTTP requests.",
            reporter_options: [
              buckets: exponential!(1, 2, 12)
            ],
            tag_values: get_conn_tags(routers, additional_routes),
            tags: http_metrics_tags,
            unit: {:native, :millisecond}
          ),

          # Capture response payload size information
          distribution(
            metric_prefix ++ [:http, :response, :size, :bytes],
            event_name: @stop_event,
            description: "The size of the HTTP response payload.",
            reporter_options: [
              buckets: exponential!(1, 4, 12)
            ],
            measurement: fn _measurements, metadata ->
              case metadata.conn.resp_body do
                nil -> 0
                _ -> :erlang.iolist_size(metadata.conn.resp_body)
              end
            end,
            tag_values: get_conn_tags(routers, additional_routes),
            tags: http_metrics_tags,
            unit: :byte
          ),

          # Capture the number of requests that have been serviced
          counter(
            metric_prefix ++ [:http, :requests, :total],
            event_name: @stop_event,
            description: "The number of requests have been serviced.",
            tag_values: get_conn_tags(routers, additional_routes),
            tags: http_metrics_tags
          )
        ]
      )
    end

    defp channel_events(metric_prefix) do
      Event.build(
        :phoenix_channel_event_metrics,
        [
          # Capture the number of channel joins that have occurred
          counter(
            metric_prefix ++ [:channel, :joined, :total],
            event_name: [:phoenix, :channel_joined],
            description: "The number of channel joins that have occurred.",
            tag_values: fn %{result: result, socket: %Socket{transport: transport, endpoint: endpoint}} ->
              %{
                transport: transport,
                result: result,
                endpoint: normalize_module_name(endpoint)
              }
            end,
            tags: [:result, :transport, :endpoint]
          ),

          # Capture channel handle_in duration
          distribution(
            metric_prefix ++ [:channel, :handled_in, :duration, :milliseconds],
            event_name: [:phoenix, :channel_handled_in],
            measurement: :duration,
            description: "The time it takes for the application to respond to channel messages.",
            reporter_options: [
              buckets: exponential!(1, 2, 12)
            ],
            tag_values: fn %{socket: %Socket{endpoint: endpoint}} ->
              %{
                endpoint: normalize_module_name(endpoint)
              }
            end,
            tags: [:endpoint],
            unit: {:native, :millisecond}
          )
        ]
      )
    end

    defp socket_events(metric_prefix) do
      Event.build(
        :phoenix_socket_event_metrics,
        [
          # Capture socket connection duration
          distribution(
            metric_prefix ++ [:socket, :connected, :duration, :milliseconds],
            event_name: [:phoenix, :socket_connected],
            measurement: :duration,
            description: "The time it takes for the application to establish a socket connection.",
            reporter_options: [
              buckets: exponential!(1, 2, 12)
            ],
            tag_values: fn %{result: result, endpoint: endpoint, transport: transport} ->
              %{
                transport: transport,
                result: result,
                endpoint: normalize_module_name(endpoint)
              }
            end,
            tags: [:result, :transport, :endpoint],
            unit: {:native, :millisecond}
          )
        ]
      )
    end

    defp get_conn_tags(routers, []) do
      fn
        %{conn: %Conn{} = conn} ->
          default_route_tags = %{
            path: "Unknown",
            controller: "Unknown",
            action: "Unknown"
          }

          conn
          |> do_get_router_info(routers, default_route_tags)
          |> Map.merge(%{
            status: conn.status,
            method: conn.method
          })

        _ ->
          # TODO: Change this to warning as warn is deprecated as of Elixir 1.11
          Logger.warn("Could not resolve path for request")
      end
    end

    defp get_conn_tags(routers, additional_routes) do
      fn
        %{conn: %Conn{} = conn} ->
          default_route_tags = handle_additional_routes_check(conn, additional_routes)

          conn
          |> do_get_router_info(routers, default_route_tags)
          |> Map.merge(%{
            status: conn.status,
            method: conn.method
          })

        _ ->
          # TODO: Change this to warning as warn is deprecated as of Elixir 1.11
          Logger.warn("Could not resolve path for request")
      end
    end

    defp do_get_router_info(conn, routers, default_route_tags) do
      routers
      |> Enum.find_value(default_route_tags, fn router ->
        case Phoenix.Router.route_info(router, conn.method, conn.request_path, "") do
          :error ->
            false

          %{route: path, plug: controller, plug_opts: action} ->
            %{
              path: path,
              controller: normalize_module_name(controller),
              action: normalize_action(action)
            }
        end
      end)
    end

    defp handle_additional_routes_check(%Conn{request_path: request_path}, additional_routes) do
      default_tags = %{
        path: "Unknown",
        controller: "Unknown",
        action: "Unknown"
      }

      additional_routes
      |> Enum.find_value(default_tags, fn {path_label, route_check} ->
        cond do
          is_binary(route_check) and route_check == request_path ->
            %{
              path: path_label,
              controller: "NA",
              action: "NA"
            }

          match?(%Regex{}, route_check) and Regex.match?(route_check, request_path) ->
            %{
              path: path_label,
              controller: "NA",
              action: "NA"
            }

          true ->
            false
        end
      end)
    end

    defp set_up_telemetry_proxy(phoenix_event_prefixes) do
      phoenix_event_prefixes
      |> Enum.each(fn telemetry_prefix ->
        stop_event = telemetry_prefix ++ [:stop]

        :telemetry.attach(
          [:prom_ex, :phoenix, :proxy] ++ telemetry_prefix,
          stop_event,
          &__MODULE__.handle_proxy_phoenix_event/4,
          %{}
        )
      end)
    end

    @doc false
    def handle_proxy_phoenix_event(_event_name, event_measurement, event_metadata, _config) do
      :telemetry.execute(@stop_event, event_measurement, event_metadata)
    end

    defp normalize_module_name(name) when is_atom(name) do
      name
      |> Atom.to_string()
      |> String.trim_leading("Elixir.")
    end

    defp normalize_module_name(name), do: name

    defp normalize_action(action) when is_atom(action), do: action
    defp normalize_action(_action), do: "Unknown"

    defp fetch_additional_routes!(opts) do
      opts
      |> fetch_either!(:router, :endpoints)
      |> case do
        endpoints when is_list(endpoints) ->
          endpoints
          |> Enum.flat_map(fn
            {_endpoint, endpoint_opts} ->
              Keyword.get(endpoint_opts, :additional_routes, [])
          end)
          |> MapSet.new()
          |> MapSet.to_list()

        _router ->
          Keyword.get(opts, :additional_routes, [])
      end
    end

    defp fetch_event_prefixes!(opts) do
      opts
      |> fetch_either!(:router, :endpoints)
      |> case do
        endpoints when is_list(endpoints) ->
          endpoints
          |> Enum.map(fn
            {_endpoint, endpoint_opts} ->
              Keyword.get(endpoint_opts, :event_prefix, [:phoenix, :endpoint])
          end)

        _router ->
          [Keyword.get(opts, :event_prefix, [:phoenix, :endpoint])]
      end
      |> MapSet.new()
      |> MapSet.to_list()
    end

    defp fetch_routers!(opts) do
      opts
      |> fetch_either!(:router, :endpoints)
      |> case do
        endpoints when is_list(endpoints) ->
          endpoints
          |> Enum.flat_map(fn
            {_endpoint, endpoint_opts} ->
              endpoint_opts
              |> Keyword.fetch!(:routers)
          end)
          |> MapSet.new()
          |> MapSet.to_list()

        router ->
          [router]
      end
    end

    defp fetch_either!(keywordlist, key1, key2) do
      case {Keyword.has_key?(keywordlist, key1), Keyword.has_key?(keywordlist, key2)} do
        {true, _} ->
          keywordlist[key1]

        {false, true} ->
          keywordlist[key2]

        {false, false} ->
          raise KeyError, "Neither #{inspect(key1)} nor #{inspect(key2)} found in #{inspect(keywordlist)}"
      end
    end
  end
else
  defmodule PromEx.Plugins.Phoenix do
    @moduledoc false
    use PromEx.Plugin

    @impl true
    def event_metrics(_opts) do
      PromEx.Plugin.no_dep_raise(__MODULE__, "Phoenix")
    end
  end
end