lib/new_relic/telemetry/tesla.ex

defmodule NewRelicTesla.Telemetry.Tesla do
  @moduledoc """
  Provides `Tesla` instrumentation via `telemetry`.

  Tesla pipelines are auto-discovered and instrumented.

  We automatically gather:

  * Transaction metrics and events
  * Transaction spans
  """
  use GenServer

  alias NewRelic.Transaction.Reporter

  @doc false
  def start_link(_) do
    config = %{
      handler_id: {:new_relic, :tesla}
    }

    GenServer.start_link(__MODULE__, config, name: __MODULE__)
  end

  @tesla_stop [:tesla, :request, :stop]
  @tesla_exception [:tesla, :request, :exception]

  @tesla_events [
    @tesla_stop,
    @tesla_exception
  ]

  @doc false
  @impl GenServer
  def init(config) do
    :telemetry.attach_many(
      config.handler_id,
      @tesla_events,
      &__MODULE__.handle_event/4,
      config
    )

    Process.flag(:trap_exit, true)
    {:ok, config}
  end

  @doc false
  @impl GenServer
  def terminate(_reason, %{handler_id: handler_id}) do
    :telemetry.detach(handler_id)
  end

  def handle_event(
        _event,
        %{duration: duration_ns} = _measurements,
        metadata,
        _config
      ) do
    end_time = System.system_time(:microsecond) / 1000

    duration_ms = to_ms(duration_ns)
    duration_s = duration_ms / 1000
    start_time = end_time - duration_ms

    pid = inspect(self())
    id = {:tesla_request, make_ref()}
    parent_id = Process.get(:nr_current_span) || :root

    span_attrs = build_attrs(metadata)

    %{
      "http.method" => method,
      "http.target" => target,
      "http.host" => host
    } = span_attrs

    metric_name = "Tesla/#{method} #{host}#{target}"

    Reporter.add_trace_segment(%{
      primary_name: metric_name,
      secondary_name: metric_name,
      attributes: span_attrs,
      id: id,
      pid: pid,
      parent_id: parent_id,
      start_time: start_time,
      end_time: end_time
    })

    NewRelic.report_span(
      timestamp_ms: start_time,
      duration_s: duration_s,
      name: metric_name,
      edge: [span: id, parent: parent_id],
      category: "external",
      attributes:
        %{
          component: "Tesla",
          "span.kind": :client
        }
        |> Map.merge(span_attrs)
    )

    metric_identifier = {:external, "#{host}#{target}", "Tesla", method}
    NewRelic.report_metric(metric_identifier, duration_s: duration_s)
    Reporter.track_metric({metric_identifier, duration_s: duration_s})

    NewRelic.incr_attributes(
      externalCallCount: 1,
      externalDuration: duration_s,
      external_call_count: 1,
      external_duration_ms: duration_ms,
      "external.#{host}.call_count": 1,
      "external.#{host}.duration_ms": duration_ms
    )
  end

  def handle_event(_event, _value, _metadata, _config) do
    :ignore
  end

  defp to_ms(nil), do: nil
  defp to_ms(ns), do: System.convert_time_unit(ns, :nanosecond, :microsecond) / 1000

  defp build_attrs(%{
         env: %Tesla.Env{
           method: method,
           url: url,
           status: status_code,
           headers: headers,
           query: query
         }
       }) do
    url = Tesla.build_url(url, query)
    uri = URI.parse(url)

    attrs = %{
      "http.method" => http_method(method),
      "http.url" => url,
      "http.target" => uri.path,
      "http.host" => uri.host,
      "http.scheme" => uri.scheme,
      "http.status_code" => status_code
    }

    maybe_append_content_length(attrs, headers)
  end

  defp maybe_append_content_length(attrs, headers) do
    case Enum.find(headers, fn {k, _v} -> k == "content-length" end) do
      nil ->
        attrs

      {_key, content_length} ->
        Map.put(attrs, :"http.response_content_length", content_length)
    end
  end

  defp http_method(method) do
    method
    |> Atom.to_string()
    |> String.upcase()
  end
end