lib/teleplug.ex

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

  alias Plug.Conn

  require Logger
  require OpenTelemetry.SemanticConventions.Trace, as: Conventions
  require OpenTelemetry.Tracer, as: Tracer
  require Record

  @http_client_ip Atom.to_string(Conventions.http_client_ip())
  @http_flavor Atom.to_string(Conventions.http_flavor())
  @http_method Atom.to_string(Conventions.http_method())
  @http_route Atom.to_string(Conventions.http_route())
  @http_scheme Atom.to_string(Conventions.http_scheme())
  @http_status_code Atom.to_string(Conventions.http_status_code())
  @http_target Atom.to_string(Conventions.http_target())
  @http_user_agent Atom.to_string(Conventions.http_user_agent())

  @net_host_name Atom.to_string(Conventions.net_host_name())
  @net_host_port Atom.to_string(Conventions.net_host_port())
  @net_sock_peer_port Atom.to_string(Conventions.net_sock_peer_port())
  @net_sock_peer_addr Atom.to_string(Conventions.net_sock_peer_addr())

  @behaviour Plug

  defdelegate setup, to: Teleplug.Instrumentation

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    :otel_propagator_text_map.extract(conn.req_headers)

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

    parent_ctx = Tracer.current_span_ctx()

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

    Tracer.set_current_span(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_status_code, conn.status)
      Tracer.end_span()

      Tracer.set_current_span(parent_ctx)
      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{
           adapter: adapter,
           method: method,
           scheme: scheme
         } = conn
       ) do
    route = Teleplug.Instrumentation.get_route(conn.request_path)

    [
      {@http_method, method},
      {@http_route, route},
      {@http_target, http_target(conn)},
      {@http_scheme, scheme},
      {@http_flavor, http_flavor(adapter)},
      {@http_user_agent, 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: [{@http_client_ip, 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{
           host: host,
           port: port
         } = conn
       ) do
    peer_data = Plug.Conn.get_peer_data(conn)

    [
      {@net_sock_peer_addr, peer_data |> Map.get(:address) |> :inet_parse.ntoa() |> to_string()},
      {@net_sock_peer_port, Map.get(peer_data, :port)},
      {@net_host_name, host},
      {@net_host_port, port}
    ]
  end

  defp http_target(%Conn{request_path: request_path, query_string: ""}),
    do: request_path

  defp http_target(%Conn{request_path: request_path, query_string: query_string}),
    do: "#{request_path}?#{query_string}"

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

  defp http_flavor({_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