lib/teleplug.ex

defmodule Teleplug do
  @moduledoc """
  Simple opentelementry instrumented plug.
  """

  alias Plug.Conn

  alias OpenTelemetry.SemConv.ClientAttributes
  alias OpenTelemetry.SemConv.HTTPAttributes
  alias OpenTelemetry.SemConv.NetworkAttributes
  alias OpenTelemetry.SemConv.ServerAttributes
  alias OpenTelemetry.SemConv.URLAttributes
  alias OpenTelemetry.SemConv.UserAgentAttributes

  alias Teleplug.RequestMonitor

  require Logger

  require OpenTelemetry.Tracer, as: Tracer
  require Record

  @client_address Atom.to_string(ClientAttributes.client_address())

  @http_request_method Atom.to_string(HTTPAttributes.http_request_method())
  @http_response_status_code Atom.to_string(HTTPAttributes.http_response_status_code())
  @http_route Atom.to_string(HTTPAttributes.http_route())

  @url_path Atom.to_string(URLAttributes.url_path())
  @url_query Atom.to_string(URLAttributes.url_query())
  @url_scheme Atom.to_string(URLAttributes.url_scheme())

  @user_agent_original Atom.to_string(UserAgentAttributes.user_agent_original())

  @server_address Atom.to_string(ServerAttributes.server_address())
  @server_port Atom.to_string(ServerAttributes.server_port())

  @network_peer_address Atom.to_string(NetworkAttributes.network_peer_address())
  @network_peer_port Atom.to_string(NetworkAttributes.network_peer_port())
  @network_protocol_name Atom.to_string(NetworkAttributes.network_protocol_name())

  @behaviour Plug

  defdelegate setup, to: Teleplug.Instrumentation

  @typedoc """
  Configuration options for Teleplug
    * trace_propagation_opt(default: `:as_parent`) - configure how trace propagation works.
  """
  @type opts() :: [
          trace_propagation: trace_propagation_opt()
        ]

  @typedoc "
  How to handle trace propagation headers:
   - `:as_parent` set the propagated trace span as a parent for the request handler span.
    Note that if the parent span doesn't exist your trace might be dropped by the opentelemetry collector.
    If that behaviour is undesirable(eg. when using a public endpoint) you should use the `:as_link` option.
   - `:as_link` create a [link](https://opentelemetry.io/docs/concepts/signals/traces/#span-links) between the propagated span and the request handler span.
   - `:disabled` disable trace propagation.
  "
  @type trace_propagation_opt() :: :as_parent | :as_link | :disabled

  @impl true
  @spec init(opts() | nil) :: opts()
  def init(opts) when is_list(opts) do
    # validate and set defaults for all options
    {trace_propagation, opts} = Keyword.pop(opts, :trace_propagation, :as_parent)

    if trace_propagation not in [:as_parent, :as_link, :disabled] do
      raise ArgumentError,
        message: "invalid trace_propagation configuration value: #{trace_propagation}"
    end

    unused_keys = Keyword.keys(opts)

    if unused_keys != [] do
      raise ArgumentError, message: "Unknown teleplug options: #{unused_keys}"
    end

    [
      trace_propagation: trace_propagation
    ]
  end

  def init(opts) when is_nil(opts), do: init([])

  @impl true
  @spec call(Plug.Conn.t(), opts()) :: Plug.Conn.t()
  def call(conn, opts) do
    trace_propagation = Keyword.fetch!(opts, :trace_propagation)

    attributes =
      http_common_attributes(conn) ++
        http_server_attributes(conn) ++
        network_attributes(conn)

    if trace_propagation == :as_parent do
      :otel_propagator_text_map.extract(conn.req_headers)
    end

    links =
      case trace_propagation do
        :as_link ->
          OpenTelemetry.Ctx.new()
          |> :otel_propagator_text_map.extract_to(conn.req_headers)
          |> OpenTelemetry.Tracer.current_span_ctx()
          |> OpenTelemetry.link()
          |> List.wrap()

        _ ->
          []
      end

    new_ctx =
      conn
      |> span_name()
      |> Tracer.start_span(%{kind: :server, attributes: attributes, links: links})

    Tracer.set_current_span(new_ctx)

    request_monitor_ref = RequestMonitor.start(new_ctx)

    Conn.register_before_send(conn, fn conn ->
      # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/http.md#status
      if conn.status >= 500 do
        Tracer.set_status(:error, "")
      end

      Tracer.set_attribute(@http_response_status_code, conn.status)

      if request_monitor_ref != nil do
        RequestMonitor.end_span(request_monitor_ref)
      end

      conn
    end)
  end

  # see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#name
  defp span_name(conn),
    do: "#{conn.method} #{Teleplug.Instrumentation.get_route(conn.request_path)}"

  # see https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/http.md#common-attributes
  defp http_common_attributes(
         %Conn{
           method: method,
           scheme: scheme
         } = conn
       ) do
    route = Teleplug.Instrumentation.get_route(conn.request_path)

    [
      {@http_request_method, method},
      {@http_route, route},
      {@url_path, url_path(conn)},
      {@url_scheme, scheme},
      {@url_query, url_query(conn)},
      {@user_agent_original, header_value(conn, "user-agent")}
    ]
  end

  # see https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/http.md#http-server-semantic-conventions
  defp http_server_attributes(conn),
    do: [{@client_address, client_ip(conn)}]

  # see https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/span-general.md#general-network-connection-attributes
  defp network_attributes(
         %Conn{
           adapter: adapter,
           host: host,
           port: port
         } = conn
       ) do
    peer_data = Plug.Conn.get_peer_data(conn)

    [
      {@network_peer_address,
       peer_data |> Map.get(:address) |> :inet_parse.ntoa() |> to_string()},
      {@network_peer_port, Map.get(peer_data, :port)},
      {@network_protocol_name, network_protocol_name(adapter)},
      {@server_address, host},
      {@server_port, port}
    ]
  end

  defp url_path(%Conn{request_path: request_path}),
    do: request_path

  defp url_query(%Conn{query_string: query_string}),
    do: query_string

  defp header_value(conn, header),
    do:
      conn
      |> Plug.Conn.get_req_header(header)
      |> List.first()
      |> to_string()

  defp network_protocol_name({_adapter_name, meta}),
    do:
      meta
      |> Map.get(:version)
      |> to_string()
      |> String.trim_leading("HTTP/")

  defp client_ip(%Conn{remote_ip: remote_ip} = conn) do
    case Plug.Conn.get_req_header(conn, "x-forwarded-for") do
      [] ->
        to_string(:inet_parse.ntoa(remote_ip))

      [client | _] ->
        client
    end
  end
end