lib/prom_ex/plugins/phoenix_live_view.ex

if Code.ensure_loaded?(Phoenix.LiveView) do
  defmodule PromEx.Plugins.PhoenixLiveView do
    @moduledoc """
    This plugin captures metrics emitted by PhoenixLiveView. Specifically, it captures events related to the
    mount, handle_event, and handle_params callbacks for live views and live components.

    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_live_view]`. If this changes you will also want to set
      `phoenix_live_view_metric_prefix` in your `dashboard_assigns` to the snakecase version of your
      prefix, the default `phoenix_live_view_metric_prefix` is `{otp_app}_prom_ex_phoenix_live_view`.

    - `duration_unit`: This is an OPTIONAL option and is a `Telemetry.Metrics.time_unit()`. It can be one of:
      `:second | :millisecond | :microsecond | :nanosecond`. It is `:millisecond` by default.

    This plugin exposes the following metric groups:
    - `:phoenix_live_view_event_metrics`
    - `:phoenix_live_view_component_event_metrics`

    To use plugin in your application, add the following to your PromEx module:
    ```
    defmodule WebApp.PromEx do
      use PromEx, otp_app: :web_app

      @impl true
      def plugins do
        [
          ...
          PromEx.Plugins.PhoenixLiveView
        ]
      end

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

    use PromEx.Plugin

    alias Phoenix.LiveView.Socket
    alias PromEx.Utils

    @live_view_mount_stop [:phoenix, :live_view, :mount, :stop]
    @live_view_mount_exception [:phoenix, :live_view, :mount, :exception]

    @live_view_handle_event_stop [:phoenix, :live_view, :handle_event, :stop]
    @live_view_handle_event_exception [:phoenix, :live_view, :handle_event, :exception]

    # Coming soon
    # @live_view_handle_params_stop [:phoenix, :live_view, :handle_params, :stop]
    # @live_view_handle_params_exception [:phoenix, :live_view, :handle_params, :exception]

    # Coming soon
    # @live_component_handle_event_stop [:phoenix, :live_component, :handle_event, :stop]
    # @live_component_handle_event_exception [:phoenix, :live_component, :handle_event, :exception]

    @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_live_view))
      duration_unit = Keyword.get(opts, :duration_unit, :millisecond)

      # Event metrics definitions
      [
        live_view_event_metrics(metric_prefix, duration_unit),
        live_component_event_metrics(metric_prefix)
      ]
    end

    defp live_view_event_metrics(metric_prefix, duration_unit) do
      bucket_intervals = [10, 100, 500, 1_000, 2_500, 5_000, 10_000]
      duration_unit_plural = String.to_atom("#{duration_unit}s")

      Event.build(
        :phoenix_live_view_event_metrics,
        [
          distribution(
            metric_prefix ++ [:mount, :duration, duration_unit_plural],
            event_name: @live_view_mount_stop,
            measurement: :duration,
            description: "The time it takes for the live view to complete the mount callback.",
            reporter_options: [
              buckets: bucket_intervals
            ],
            tag_values: &get_mount_socket_tags/1,
            tags: [:action, :module],
            unit: {:native, duration_unit}
          ),
          distribution(
            metric_prefix ++ [:mount, :exception, :duration, duration_unit_plural],
            event_name: @live_view_mount_exception,
            measurement: :duration,
            description:
              "The time it takes for the live view to complete the mount callback that resulted in an exception",
            reporter_options: [
              buckets: bucket_intervals
            ],
            tag_values: &get_mount_exception_tags/1,
            tags: [:action, :module, :kind, :reason],
            unit: {:native, duration_unit}
          ),
          distribution(
            metric_prefix ++ [:handle_event, :duration, duration_unit_plural],
            event_name: @live_view_handle_event_stop,
            measurement: :duration,
            description: "The time it takes for the live view to complete the handle_event callback.",
            reporter_options: [
              buckets: bucket_intervals
            ],
            tag_values: &get_handle_event_socket_tags/1,
            tags: [:event, :action, :module],
            unit: {:native, duration_unit}
          ),
          distribution(
            metric_prefix ++ [:handle_event, :exception, :duration, duration_unit_plural],
            event_name: @live_view_handle_event_exception,
            measurement: :duration,
            description:
              "The time it takes for the live view to complete the handle_event callback that resulted in an exception.",
            reporter_options: [
              buckets: bucket_intervals
            ],
            tag_values: &get_handle_event_exception_socket_tags/1,
            tags: [:event, :action, :module, :kind, :reason],
            unit: {:native, duration_unit}
          )
        ]
      )
    end

    defp live_component_event_metrics(_metric_prefix) do
      Event.build(
        :phoenix_live_view_component_event_metrics,
        []
      )
    end

    defp get_handle_event_exception_socket_tags(%{socket: socket = %Socket{}} = metadata) do
      %{
        event: metadata.event,
        action: get_live_view_action(socket),
        module: get_live_view_module(socket),
        kind: metadata.kind,
        reason: Utils.normalize_exception(metadata.kind, metadata.reason, metadata.stacktrace)
      }
    end

    defp get_handle_event_socket_tags(%{socket: socket = %Socket{}} = metadata) do
      %{
        event: metadata.event,
        action: get_live_view_action(socket),
        module: get_live_view_module(socket)
      }
    end

    defp get_mount_socket_tags(%{socket: socket = %Socket{}}) do
      %{
        action: get_live_view_action(socket),
        module: get_live_view_module(socket)
      }
    end

    defp get_mount_exception_tags(%{socket: socket = %Socket{}} = metadata) do
      %{
        action: get_live_view_action(socket),
        module: get_live_view_module(socket),
        kind: metadata.kind,
        reason: Utils.normalize_exception(metadata.kind, metadata.reason, metadata.stacktrace)
      }
    end

    defp get_live_view_module(%Socket{} = socket) do
      socket
      |> Map.get(:view, :unknown)
      |> normalize_module_name()
    end

    defp get_live_view_action(%Socket{} = socket) do
      socket.assigns
      |> Map.get(:live_action, :unknown)
    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
  end
else
  defmodule PromEx.Plugins.PhoenixLiveView do
    @moduledoc false
    use PromEx.Plugin

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