lib/appsignal/ecto.ex

defmodule Appsignal.Ecto do
  require Appsignal.Utils

  @tracer Appsignal.Utils.compile_env(:appsignal, :appsignal_tracer, Appsignal.Tracer)
  @span Appsignal.Utils.compile_env(:appsignal, :appsignal_span, Appsignal.Span)
  import Appsignal.Utils, only: [module_name: 1, warning: 1]

  @doc """
  Attaches `Appsignal.Ecto` to the Ecto telemetry channel configured in the
  application's configuration.
  """
  def attach do
    otp_app =
      :appsignal
      |> Application.get_env(:config, %{})
      |> Map.get(:otp_app, nil)

    otp_app
    |> repos()
    |> Enum.each(&attach(otp_app, &1))
  end

  @doc """
  Attaches `Appsignal.Ecto` to the Ecto telemetry channel based on the passed
  `otp_app` and `repo`.
  """
  def attach(otp_app, repo) do
    event = telemetry_prefix(otp_app, repo) ++ [:query]

    case :telemetry.attach({__MODULE__, event}, event, &__MODULE__.handle_event/4, :ok) do
      :ok ->
        Appsignal.IntegrationLogger.debug("Appsignal.Ecto attached to #{inspect(event)}")

        :ok

      {:error, _} = error ->
        warning("Appsignal.Ecto not attached to #{inspect(event)}: #{inspect(error)}")

        error
    end
  end

  defp repos(otp_app) do
    Application.get_env(:appsignal, :config, %{})[:ecto_repos] ||
      Application.get_env(otp_app, :ecto_repos) ||
      []
  end

  defp telemetry_prefix(otp_app, repo) do
    case otp_app
         |> Application.get_env(repo, [])
         |> Keyword.get(:telemetry_prefix) do
      prefix when is_list(prefix) ->
        prefix

      _ ->
        repo
        |> Module.split()
        |> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom()))
    end
  end

  @doc false
  def handle_event(
        _event,
        %{total_time: total_time},
        %{repo: repo, query: query} = metadata,
        _config
      ) do
    do_handle_event(current_span(metadata), total_time, repo, query)
  end

  def handle_event(_event, _measurements, _metadata, _config), do: :ok

  defp current_span(metadata) do
    metadata[:options][:_appsignal_current_span] || @tracer.current_span()
  end

  defp do_handle_event(nil, _total_time, _repo, _query), do: nil

  defp do_handle_event(current_span, total_time, repo, query) do
    case query do
      "begin" -> handle_begin(current_span, total_time, repo)
      "commit" -> handle_commit(current_span, total_time, repo)
      "rollback" -> handle_rollback(current_span, total_time, repo)
      _ -> handle_query(current_span, total_time, repo, query)
    end
  end

  defp handle_begin(current_span, total_time, repo) do
    time = :os.system_time()

    # Intentionally leave span open to be closed
    # by `handle_commit/3` or `handle_rollback/3`
    "http_request"
    |> @tracer.create_span(current_span, start_time: time - total_time)
    |> @span.set_name("Transaction #{module_name(repo)}")
    |> @span.set_attribute("appsignal:category", "transaction.ecto")
  end

  defp handle_commit(current_span, total_time, repo) do
    time = :os.system_time()

    "http_request"
    |> @tracer.create_span(current_span, start_time: time - total_time)
    |> @span.set_name("Commit #{module_name(repo)}")
    |> @span.set_attribute("appsignal:category", "commit.ecto")
    |> @tracer.close_span(end_time: time)

    # Close span created by `handle_begin/3`
    @tracer.close_span(current_span, end_time: time)
  end

  defp handle_rollback(current_span, total_time, repo) do
    time = :os.system_time()

    "http_request"
    |> @tracer.create_span(current_span, start_time: time - total_time)
    |> @span.set_name("Rollback #{module_name(repo)}")
    |> @span.set_attribute("appsignal:category", "rollback.ecto")
    |> @tracer.close_span(end_time: time)

    # Close span created by `handle_begin/3`
    @tracer.close_span(current_span, end_time: time)
  end

  defp handle_query(current_span, total_time, repo, query) do
    time = :os.system_time()

    "http_request"
    |> @tracer.create_span(current_span, start_time: time - total_time)
    |> @span.set_name("Query #{module_name(repo)}")
    |> @span.set_attribute("appsignal:category", "query.ecto")
    |> @span.set_sql(query)
    |> @tracer.close_span(end_time: time)
  end
end