Skip to main content

lib/opentelemetry_plug.ex

defmodule OpentelemetryPlug do
  @moduledoc """
  Telemetry handler for creating OpenTelemetry Spans from Plug events.
  """

  require Logger
  require OpenTelemetry.Tracer, as: Tracer

  alias OpenTelemetry.SemConv.{
    ClientAttributes,
    HTTPAttributes,
    NetworkAttributes,
    ServerAttributes,
    URLAttributes,
    UserAgentAttributes
  }

  alias OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: IncubatingHTTPAttributes

  alias OpenTelemetry.Span

  @dialyzer {:nowarn_function, client_info: 3}

  @default_request_headers_to_trace [
    "accept",
    "content-type",
    "origin",
    "traceparent",
    "tracestate",
    "x-forwarded-for",
    "x-forwarded-proto",
    "x-request-id",
    "x-sre-trace"
  ]

  @default_response_headers_to_trace [
    "content-type",
    "x-request-id"
  ]

  defmodule Propagation do
    @moduledoc """
    Adds OpenTelemetry context propagation headers to the Plug response.

    ### WARNING

    These context headers are potentially dangerous to expose to third-parties.
    W3C recommends against including them except in cases where both client and
    server participate in the trace.

    See https://www.w3.org/TR/trace-context/#other-risks for more information.
    """

    @behaviour Plug
    import Plug.Conn, only: [register_before_send: 2, merge_resp_headers: 2]

    @impl true
    def init(opts) do
      opts
    end

    @impl true
    def call(conn, _opts) do
      register_before_send(conn, fn conn ->
        case OpentelemetryPlug.body_size(conn, :response) do
          nil ->
            :ok

          size ->
            Tracer.set_attribute(IncubatingHTTPAttributes.http_response_body_size(), size)
        end

        merge_resp_headers(conn, :otel_propagator_text_map.inject([]))
      end)
    end
  end

  @doc """
  Attaches the OpentelemetryPlug handler to your Plug.Router events. This
  should be called from your application behaviour on startup.

  Example:

  OpentelemetryPlug.setup()

  """
  def setup do
    ignored = ignored_routes()

    :telemetry.attach(
      {__MODULE__, :plug_router_start},
      [:plug, :router_dispatch, :start],
      &__MODULE__.handle_start/4,
      ignored
    )

    :telemetry.attach(
      {__MODULE__, :plug_router_stop},
      [:plug, :router_dispatch, :stop],
      &__MODULE__.handle_stop/4,
      ignored
    )

    :telemetry.attach(
      {__MODULE__, :plug_router_exception},
      [:plug, :router_dispatch, :exception],
      &__MODULE__.handle_exception/4,
      ignored
    )
  end

  @spec handle_start(
          any,
          any,
          %{:conn => Plug.Conn.t(), :route => any, optional(any) => any},
          any
        ) ::
          :undefined
          | {:span_ctx, non_neg_integer, non_neg_integer, integer,
             [
               {binary | maybe_improper_list(any, binary | []),
                binary | maybe_improper_list(any, binary | [])}
             ], false | true | :undefined, boolean, false | true | :undefined,
             :undefined | {atom, any}}
  @doc false
  def handle_start(_, _measurements, %{conn: conn, route: route}, ignored) do
    if not Map.has_key?(ignored, route) do
      setup_span(conn, route)
    end
  end

  @doc false
  def handle_stop(_, _measurements, %{conn: conn, route: route}, ignored) do
    disabled? = Map.has_key?(ignored, route)
    is_error? = conn.status >= 400
    record? = not disabled? or is_error?
    # For HTTP status codes in the 4xx and 5xx ranges, as well as any other
    # code the client failed to interpret, status MUST be set to Error.
    #
    # Don't set the span status description if the reason can be inferred from
    # http.response.status_code.
    if is_error? and disabled? do
      setup_span(conn, route)
    end

    if is_error? do
      Tracer.set_status(OpenTelemetry.status(:error, ""))
    end

    if record? do
      extract_headers(conn, :response)
      |> Enum.each(fn {key, value} -> Tracer.set_attribute(key, value) end)

      Tracer.set_attribute(HTTPAttributes.http_response_status_code(), conn.status)

      Tracer.end_span()
      restore_parent_ctx()
    end
  end

  @doc false
  def handle_exception(_, _measurements, %{conn: conn, route: route} = metadata, ignored) do
    if Map.has_key?(ignored, route) do
      setup_span(conn, route)
    end

    %{kind: kind, stacktrace: stacktrace} = metadata
    # This metadata key changed from :error to :reason in Plug 1.10.3
    reason = metadata[:reason] || metadata[:error]

    exception = Exception.normalize(kind, reason, stacktrace)

    Span.record_exception(
      Tracer.current_span_ctx(),
      exception,
      stacktrace
    )

    Tracer.set_status(OpenTelemetry.status(:error, Exception.message(exception)))
    Tracer.set_attribute(HTTPAttributes.http_response_status_code(), 500)
    Tracer.end_span()
    restore_parent_ctx()
  end

  defp setup_span(conn, route) do
    save_parent_ctx()
    # setup OpenTelemetry context based on request headers
    :otel_propagator_text_map.extract(conn.req_headers)

    span_name = span_name("#{route}", conn.request_path)
    peer_data = Plug.Conn.get_peer_data(conn)
    peer_address = to_string(:inet_parse.ntoa(Map.get(peer_data, :address)))

    {protocol, protocol_version} = protocol_info(conn.adapter)
    {client_address, client_port} = client_info(conn, peer_address, peer_data.port)

    attributes =
      %{
        ClientAttributes.client_port() => client_port,
        ClientAttributes.client_address() => client_address,
        HTTPAttributes.http_request_method() => conn.method,
        HTTPAttributes.http_route() => route,
        NetworkAttributes.network_local_address() => to_string(:inet_parse.ntoa(conn.remote_ip)),
        NetworkAttributes.network_protocol_name() => protocol,
        NetworkAttributes.network_protocol_version() => protocol_version,
        NetworkAttributes.network_peer_address() => peer_address,
        NetworkAttributes.network_transport() => "tcp",
        ServerAttributes.server_address() => conn.host,
        ServerAttributes.server_port() => conn.port,
        URLAttributes.url_path() => conn.request_path,
        URLAttributes.url_scheme() => conn.scheme
      }
      |> Map.merge(optional_attributes(conn))
      |> Map.merge(extract_headers(conn, :request))

    span_ctx = Tracer.start_span(span_name, %{attributes: attributes, kind: :server})

    Tracer.set_current_span(span_ctx)
  end

  defp span_name("/*_path", path), do: path
  defp span_name(route, _path), do: route

  defp extract_headers(conn, type) do
    headers_to_trace(type)
    |> Enum.map(&map_header(&1, conn, type))
    |> Enum.reject(&is_nil(elem(&1, 1)))
    |> Enum.into(%{})
  end

  defp map_header("x-sre-trace", conn, type) do
    value = header_or_nil(conn, "x-sre-trace", type)
    {"x-sre-trace", value}
  end

  defp map_header(header, conn, type) do
    value = header_or_nil(conn, header, type)
    {"http.#{type}.header.#{String.downcase(header)}", value}
  end

  defp header_or_nil(conn, header, :request) do
    Plug.Conn.get_req_header(conn, String.downcase(header))
    |> clean_header()
  end

  defp header_or_nil(conn, header, :response) do
    Plug.Conn.get_resp_header(conn, String.downcase(header))
    |> clean_header()
  end

  defp clean_header(header) do
    case header do
      [] ->
        nil

      ["" | _] ->
        nil

      [value | _] ->
        value
    end
  end

  defp optional_attributes(conn) do
    %{
      UserAgentAttributes.user_agent_original() => &header_or_nil(&1, "user-agent", :request),
      IncubatingHTTPAttributes.http_request_body_size() => &body_size(&1, :request),
      URLAttributes.url_query() => &get_query_string/1
    }
    |> Enum.map(fn {attr, fun} -> {attr, fun.(conn)} end)
    |> Enum.reject(&is_nil(elem(&1, 1)))
    |> Enum.into(%{})
  end

  defp client_info(conn, peer_ip, peer_port) do
    case :otel_http.extract_client_info(conn.req_headers) do
      %{ip: :undefined, port: :undefined} -> {peer_ip, peer_port}
      %{ip: ip, port: :undefined} -> {ip, peer_port}
      %{ip: :undefined, port: port} -> {peer_ip, port}
      %{ip: ip, port: port} -> {ip, port}
    end
  end

  defp get_query_string(conn) do
    case conn.query_string do
      "" -> nil
      qs -> qs
    end
  end

  def body_size(conn, type) do
    case header_or_nil(conn, "content-length", type) do
      nil ->
        body_size_from_conn(conn, type)

      value ->
        case Integer.parse(value) do
          {size, _} -> size
          :error -> nil
        end
    end
  end

  defp body_size_from_conn(conn, type) do
    case type do
      :request ->
        nil

      :response ->
        case conn.resp_body do
          nil -> nil
          body -> IO.iodata_length(body)
        end
    end
  end

  defp protocol_info({_adapter_name, meta}) do
    version = Map.get(meta, :version)
    map_http_version(version)
  end

  defp map_http_version(:"HTTP/1.0"), do: {"http", :"1.0"}
  defp map_http_version(:"HTTP/1"), do: {"http", :"1.0"}
  defp map_http_version(:"HTTP/1.1"), do: {"http", :"1.1"}
  defp map_http_version(:"HTTP/2.0"), do: {"http", :"2.0"}
  defp map_http_version(:"HTTP/2"), do: {"http", :"2.0"}
  defp map_http_version(:"HTTP/3.0"), do: {"http", :"3.0"}
  defp map_http_version(:"HTTP/3"), do: {"http", :"3.0"}
  defp map_http_version(:SPDY), do: {"SPDY", :"2"}
  defp map_http_version(:QUIC), do: {"QUIC", :"3"}
  defp map_http_version(_other), do: {"", :""}

  @ctx_key {__MODULE__, :parent_ctx}
  defp save_parent_ctx do
    ctx = Tracer.current_span_ctx()
    Process.put(@ctx_key, ctx)
  end

  defp restore_parent_ctx do
    ctx = Process.get(@ctx_key, :undefined)
    Process.delete(@ctx_key)
    Tracer.set_current_span(ctx)
  end

  defp ignored_routes do
    Application.get_env(:opentelemetry_plug, :ignored_routes, [])
    |> log_ignored_route()
    |> Enum.into(%{}, fn key -> {key, true} end)
  end

  defp log_ignored_route(routes) do
    if not Enum.empty?(routes) do
      Logger.warning("OpentelemetryPlug is ignoring the following routes: #{inspect(routes)}")
    end

    routes
  end

  defp headers_to_trace(:request) do
    Application.get_env(
      :opentelemetry_plug,
      :request_headers_to_trace,
      @default_request_headers_to_trace
    ) ++
      Application.get_env(:opentelemetry_plug, :extra_request_headers_to_trace, [])
  end

  defp headers_to_trace(:response) do
    Application.get_env(
      :opentelemetry_plug,
      :response_headers_to_trace,
      @default_response_headers_to_trace
    ) ++
      Application.get_env(:opentelemetry_plug, :extra_response_headers_to_trace, [])
  end
end