defmodule OpentelemetryPhoenix do @options_schema NimbleOptions.new!( endpoint_prefix: [ type: {:list, :atom}, default: [:phoenix, :endpoint], doc: "The endpoint prefix in your endpoint." ], adapter: [ type: {:in, [:cowboy2, nil]}, default: nil, doc: "The phoenix server adapter being used.", type_doc: ":atom" ] ) @moduledoc """ OpentelemetryPhoenix uses [telemetry](https://hexdocs.pm/telemetry/) handlers to create `OpenTelemetry` spans. Current events which are supported include endpoint start/stop, router start/stop, and router exceptions. ### Supported options #{NimbleOptions.docs(@options_schema)} If you are using PlugCowboy as your adapter you can add `:opentelemetry_cowboy` to your project and pass the `:adapter` option when calling setup. Setting this option will prevent a new span from being started and the existing cowboy span to be continued. This is the recommended setup for measuring accurate latencies. `Bandit.PhoenixAdapter` is not currently supported. ## Usage In your application start: def start(_type, _args) do :opentelemetry_cowboy.setup() OpentelemetryPhoenix.setup(adapter: :cowboy2) children = [ {Phoenix.PubSub, name: MyApp.PubSub}, MyAppWeb.Endpoint ] opts = [strategy: :one_for_one, name: MyStore.Supervisor] Supervisor.start_link(children, opts) end """ require OpenTelemetry.Tracer alias OpenTelemetry.SemanticConventions alias OpenTelemetry.Tracer alias OpentelemetryPhoenix.Reason require SemanticConventions.Trace require OpenTelemetry.Tracer @tracer_id __MODULE__ @typedoc "Setup options" @type opts :: [endpoint_prefix() | adapter()] @typedoc "The endpoint prefix in your endpoint. Defaults to `[:phoenix, :endpoint]`" @type endpoint_prefix :: {:endpoint_prefix, [atom()]} @typedoc "The phoenix server adapter being used. Optional" @type adapter :: {:adapter, :cowboy2 | term()} @doc """ Initializes and configures the telemetry handlers. """ @spec setup(opts()) :: :ok def setup(opts \\ []) do opts = NimbleOptions.validate!(opts, @options_schema) attach_endpoint_start_handler(opts) attach_endpoint_stop_handler(opts) attach_router_start_handler() attach_router_dispatch_exception_handler() :ok end @doc false def attach_endpoint_start_handler(opts) do :telemetry.attach( {__MODULE__, :endpoint_start}, opts[:endpoint_prefix] ++ [:start], &__MODULE__.handle_endpoint_start/4, %{adapter: opts[:adapter]} ) end @doc false def attach_endpoint_stop_handler(opts) do :telemetry.attach( {__MODULE__, :endpoint_stop}, opts[:endpoint_prefix] ++ [:stop], &__MODULE__.handle_endpoint_stop/4, %{adapter: opts[:adapter]} ) end @doc false def attach_router_start_handler do :telemetry.attach( {__MODULE__, :router_dispatch_start}, [:phoenix, :router_dispatch, :start], &__MODULE__.handle_router_dispatch_start/4, %{} ) end @doc false def attach_router_dispatch_exception_handler do :telemetry.attach( {__MODULE__, :router_dispatch_exception}, [:phoenix, :router_dispatch, :exception], &__MODULE__.handle_router_dispatch_exception/4, %{} ) end @doc false def handle_endpoint_start(_event, _measurements, meta, config) do Process.put({:otel_phoenix, :adapter}, config.adapter) case adapter() do :cowboy2 -> cowboy2_start() _ -> default_start(meta) end end defp cowboy2_start do OpentelemetryProcessPropagator.fetch_parent_ctx() |> OpenTelemetry.Ctx.attach() end defp default_start(meta) do %{conn: conn} = meta :otel_propagator_text_map.extract(conn.req_headers) peer_data = Plug.Conn.get_peer_data(conn) user_agent = header_value(conn, "user-agent") peer_ip = Map.get(peer_data, :address) attributes = %{ SemanticConventions.Trace.http_client_ip() => client_ip(conn), SemanticConventions.Trace.http_flavor() => http_flavor(conn.adapter), SemanticConventions.Trace.http_method() => conn.method, SemanticConventions.Trace.http_scheme() => "#{conn.scheme}", SemanticConventions.Trace.http_target() => conn.request_path, SemanticConventions.Trace.http_user_agent() => user_agent, SemanticConventions.Trace.net_host_name() => conn.host, SemanticConventions.Trace.net_sock_host_addr() => to_string(:inet_parse.ntoa(conn.remote_ip)), SemanticConventions.Trace.net_host_port() => conn.port, SemanticConventions.Trace.net_sock_peer_addr() => to_string(:inet_parse.ntoa(peer_ip)), SemanticConventions.Trace.net_peer_port() => peer_data.port, SemanticConventions.Trace.net_transport() => :"IP.TCP" } # start the span with a default name. Route name isn't known until router dispatch OpentelemetryTelemetry.start_telemetry_span(@tracer_id, "HTTP #{conn.method}", meta, %{ kind: :server, attributes: attributes }) end @doc false def handle_endpoint_stop(_event, _measurements, meta, _config) do case adapter() do :cowboy2 -> :ok _ -> default_stop(meta) end end defp default_stop(meta) do %{conn: conn} = meta # ensure the correct span is current and update the status OpentelemetryTelemetry.set_current_telemetry_span(@tracer_id, meta) Tracer.set_attribute(SemanticConventions.Trace.http_status_code(), conn.status) if conn.status >= 500 do Tracer.set_status(OpenTelemetry.status(:error, "")) end # end the Phoenix span OpentelemetryTelemetry.end_telemetry_span(@tracer_id, meta) end @doc false def handle_router_dispatch_start(_event, _measurements, meta, _config) do attributes = %{ :"phoenix.plug" => meta.plug, :"phoenix.action" => meta.plug_opts, SemanticConventions.Trace.http_route() => meta.route } Tracer.update_name("#{meta.route}") Tracer.set_attributes(attributes) end @doc false def handle_router_dispatch_exception( _event, _measurements, %{kind: kind, reason: reason, stacktrace: stacktrace}, _config ) do if OpenTelemetry.Span.is_recording(OpenTelemetry.Tracer.current_span_ctx()) do {[reason: reason], attrs} = Reason.normalize(reason) |> Keyword.split([:reason]) # try to normalize all errors to Elixir exceptions exception = Exception.normalize(kind, reason, stacktrace) # record exception and mark the span as errored Tracer.record_exception(exception, stacktrace, attrs) Tracer.set_status(OpenTelemetry.status(:error, "")) # do not close the span as endpoint stop will still be called with # more info, including the status code, which is nil at this stage end end defp http_flavor({_adapter_name, meta}) do case Map.get(meta, :version) do :"HTTP/1.0" -> :"1.0" :"HTTP/1.1" -> :"1.1" :"HTTP/2.0" -> :"2.0" :"HTTP/2" -> :"2.0" :SPDY -> :SPDY :QUIC -> :QUIC nil -> "" end end defp client_ip(%{remote_ip: remote_ip} = conn) do case header_value(conn, "x-forwarded-for") do "" -> remote_ip |> :inet_parse.ntoa() |> to_string() ip_address -> ip_address |> String.split(",", parts: 2) |> List.first() end end defp header_value(conn, header) do case Plug.Conn.get_req_header(conn, header) do [] -> "" [value | _] -> value end end defp adapter do Process.get({:otel_phoenix, :adapter}) end end