lib/metacredo/check/observability/missing_telemetry_in_liveview_mount.ex

defmodule MetaCredo.Check.Observability.MissingTelemetryInLiveviewMount do
  use MetaCredo.Check,
    category: :observability,
    base_priority: :normal,
    param_defaults: [
      lifecycle_indicators: ~W[mount componentdidmount oninit oninitialize mounted created setup],
      telemetry_indicators: ~W[telemetry emit log trace metric record measure]
    ],
    explanations: [
      check: """
      Detects component lifecycle methods (e.g. LiveView `mount/3`, React
      `componentDidMount`) without telemetry. Lifecycle events should be
      tracked for performance monitoring.
      """,
      params: [
        lifecycle_indicators: "Function name fragments indicating lifecycle hooks",
        telemetry_indicators: "Function name fragments indicating telemetry calls"
      ],
      examples: [
        elixir: [
          wrong: """
          # Mount latency is invisible to dashboards and alerting
          def mount(_params, _session, socket) do
            {:ok, assign(socket, :users, Repo.all(User))}
          end
          """,
          correct: """
          # Emit telemetry so LiveView render time shows up in metrics
          def mount(_params, _session, socket) do
            :telemetry.execute(
              [:my_app, :live_view, :mount],
              %{system_time: System.system_time()},
              %{view: __MODULE__}
            )
            {:ok, assign(socket, :users, Repo.all(User))}
          end
          """
        ]
      ]
    ]

  @impl true
  def run(%SourceFile{} = source_file, params) do
    lifecycle_indicators = params_get(params, :lifecycle_indicators)
    telemetry_indicators = params_get(params, :telemetry_indicators)

    {_, issues} =
      source_file
      |> SourceFile.ast()
      |> AST.prewalk([], fn node, acc ->
        traverse(node, acc, source_file, lifecycle_indicators, telemetry_indicators)
      end)

    issues
  end

  defp traverse(
         {:function_def, meta, children} = node,
         issues,
         source_file,
         lifecycle_indicators,
         telemetry_indicators
       )
       when is_list(meta) do
    name = Keyword.get(meta, :name, "")

    if lifecycle_function?(name, lifecycle_indicators) and
         not body_has_telemetry?(children, telemetry_indicators) do
      line = Keyword.get(meta, :line)

      issue =
        format_issue(source_file,
          message: "Lifecycle method '#{name}' without telemetry -- add performance tracking",
          trigger: to_string(name),
          line_no: line
        )

      {node, [issue | issues]}
    else
      {node, issues}
    end
  end

  defp traverse(node, issues, _sf, _li, _ti), do: {node, issues}

  defp lifecycle_function?(name, indicators) when is_binary(name) do
    lower = String.downcase(name)
    Enum.any?(indicators, &String.contains?(lower, &1))
  end

  defp lifecycle_function?(name, indicators) when is_atom(name),
    do: lifecycle_function?(Atom.to_string(name), indicators)

  defp lifecycle_function?(_, _), do: false

  defp body_has_telemetry?(children, indicators) when is_list(children),
    do: Enum.any?(children, &node_has_telemetry?(&1, indicators))

  defp body_has_telemetry?(_, _), do: false

  defp node_has_telemetry?({:function_call, meta, args}, indicators) when is_list(meta) do
    name = Keyword.get(meta, :name, "")

    if telemetry_call?(name, indicators) do
      true
    else
      is_list(args) and Enum.any?(args, &node_has_telemetry?(&1, indicators))
    end
  end

  defp node_has_telemetry?({:block, _meta, stmts}, indicators) when is_list(stmts),
    do: Enum.any?(stmts, &node_has_telemetry?(&1, indicators))

  defp node_has_telemetry?({_type, _meta, children}, indicators) when is_list(children),
    do: Enum.any?(children, &node_has_telemetry?(&1, indicators))

  defp node_has_telemetry?(_, _), do: false

  defp telemetry_call?(name, indicators) when is_binary(name) do
    lower = String.downcase(name)
    Enum.any?(indicators, &String.contains?(lower, &1))
  end

  defp telemetry_call?(_, _), do: false
end