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