defmodule OpentelemetryReq do @moduledoc """ Wraps the request in an opentelemetry span. Span names must be paramaterized, so the `req_path_params` module and step should be registered before this step. This step is expected by default and an error will be raised if the path params option is not set for the request. Spans are not created until the request is completed or errored. ## Request Options * `:span_name` - `String.t()` if provided, overrides the span name. Defaults to `nil`. * `:no_path_params` - `boolean()` when set to `true` no path params are expected for the request. Defaults to `false` * `:propagate_trace_ctx` - `boolean()` when set to `true`, trace headers will be propagated. Defaults to `false` ### Example with path_params ``` client = Req.new() |> OpentelemetryReq.attach( base_url: "http://localhost:4000", propagate_trace_ctx: true ) client |> Req.get( url: "/api/users/:user_id", path_params: [user_id: user_id] ) ``` ### Example without path_params ``` client = Req.new() |> OpentelemetryReq.attach( base_url: "http://localhost:4000", propagate_trace_ctx: true, no_path_params: true ) client |> Req.get( url: "/api/users" ) ``` If you don't set `path_params` the request will raise. """ alias OpenTelemetry.Tracer alias OpenTelemetry.SemanticConventions.Trace require Trace require Tracer require Logger def attach(%Req.Request{} = request, options \\ []) do request |> Req.Request.register_options([:span_name, :no_path_params, :propagate_trace_ctx]) |> Req.Request.merge_options(options) |> Req.Request.append_request_steps( require_path_params: &require_path_params_option/1, start_span: &start_span/1, put_trace_headers: &maybe_put_trace_headers/1 ) |> Req.Request.prepend_response_steps(otel_end_span: &end_span/1) |> Req.Request.prepend_error_steps(otel_end_span: &end_errored_span/1) end defp start_span(request) do span_name = span_name(request) attrs = build_req_attrs(request) parent_ctx = OpenTelemetry.Ctx.get_current() Process.put(:otel_parent_ctx, parent_ctx) Tracer.start_span(span_name, %{ attributes: attrs, kind: :client }) |> Tracer.set_current_span() request end defp end_span({request, %Req.Response{} = response}) do attrs = Map.put(%{}, Trace.http_status_code(), response.status) |> maybe_append_resp_content_length(response) Tracer.set_attributes(attrs) if response.status >= 400 do OpenTelemetry.Tracer.set_status(OpenTelemetry.status(:error, "")) end OpenTelemetry.Tracer.end_span() Process.delete(:otel_parent_ctx) |> OpenTelemetry.Ctx.attach() {request, response} end defp end_errored_span({request, exception}) do OpenTelemetry.Tracer.set_status(OpenTelemetry.status(:error, format_exception(exception))) OpenTelemetry.Tracer.end_span() Process.delete(:otel_parent_ctx) |> OpenTelemetry.Ctx.attach() {request, exception} end defp format_exception(%{__exception__: true} = exception) do Exception.message(exception) end defp format_exception(_), do: "" defp span_name(request) do case request.options[:span_name] do nil -> method = http_method(request.method) case Req.Request.get_private(request, :path_params_template) do nil -> "HTTP #{method}" params_template -> "#{params_template}" end span_name -> span_name end end defp build_req_attrs(request) do uri = request.url url = sanitize_url(uri) %{ Trace.http_method() => http_method(request.method), Trace.http_url() => url, Trace.http_target() => uri.path, Trace.net_host_name() => uri.host, Trace.http_scheme() => uri.scheme } |> maybe_append_req_content_length(request) |> maybe_append_retry_count(request) end defp sanitize_url(uri) do %{uri | userinfo: nil} |> URI.to_string() end defp maybe_append_req_content_length(attrs, req) do case Req.Request.get_header(req, "content-length") do [] -> attrs [length] -> Map.put(attrs, Trace.http_request_content_length(), length) end end defp maybe_append_resp_content_length(attrs, req) do case Req.Response.get_header(req, "content-length") do [] -> attrs [length] -> Map.put(attrs, Trace.http_response_content_length(), length) end end defp maybe_append_retry_count(attrs, req) do retry_count = Req.Request.get_private(req, :req_retry_count, 0) if retry_count > 0 do Map.put(attrs, Trace.http_retry_count(), retry_count) else attrs end end defp http_method(method) do case method do :get -> :GET :head -> :HEAD :post -> :POST :patch -> :PATCH :put -> :PUT :delete -> :DELETE :connect -> :CONNECT :options -> :OPTIONS :trace -> :TRACE end end defp maybe_put_trace_headers(request) do if request.options[:propagate_trace_ctx] do propagator = :opentelemetry.get_text_map_injector() headers_to_inject = :otel_propagator_text_map.inject(propagator, [], &[{&1, &2} | &3]) Enum.reduce(headers_to_inject, request, fn {name, value}, acc -> Req.Request.put_header(acc, name, value) end) else request end end defp require_path_params_option(request) do if !request.options[:no_path_params] and !request.options[:path_params] do {Req.Request.halt(request), __MODULE__.PathParamsOptionError.new()} else request end end defmodule PathParamsOptionError do defexception [:message] def new do %__MODULE__{} end @impl true def message(_) do ":path_params option must be set" end end end