defmodule OpentelemetryReq do alias OpenTelemetry.Ctx alias OpenTelemetry.SemConv.ErrorAttributes alias OpenTelemetry.SemConv.NetworkAttributes alias OpenTelemetry.SemConv.ServerAttributes alias OpenTelemetry.SemConv.UserAgentAttributes alias OpenTelemetry.SemConv.Incubating.HTTPAttributes alias OpenTelemetry.SemConv.Incubating.URLAttributes alias OpenTelemetry.Tracer alias OpenTelemetry.SemanticConventions.Trace require Trace require Tracer require Logger opt_ins = [ HTTPAttributes.http_request_body_size(), HTTPAttributes.http_response_body_size(), NetworkAttributes.network_transport(), URLAttributes.url_scheme(), URLAttributes.url_template(), UserAgentAttributes.user_agent_original() ] @options_schema NimbleOptions.new!( opt_in_attrs: [ type: {:list, {:in, opt_ins}}, default: [], type_spec: quote(do: opt_in_attrs()), doc: """ Opt-in and experimental attributes. Use semantic conventions library to ensure compatability, e.g. `[{HTTPAttributes.http_request_body_size(), true}]` #{Enum.map_join(opt_ins, "\n\n", &" * `#{inspect(&1)}`")} """ ], propagate_trace_headers: [ type: :boolean, default: false, doc: "Trace headers will be propagated" ], request_header_attrs: [ type: {:list, :string}, default: [], doc: "List of request headers to add as attributes. (lowercase)" ], response_header_attrs: [ type: {:list, :string}, default: [], doc: "List of response headers to add as attributes. (lowercase)" ], span_name: [ type: {:or, [:atom, nil, :string]}, default: nil, doc: "User defined span name override" ] ) @typedoc "Use semantic conventions library to ensure compatability, e.g. `HTTPAttributes.http_request_body_size()`" @type opt_in_attr() :: unquote(HTTPAttributes.http_request_body_size()) | unquote(HTTPAttributes.http_response_body_size()) | unquote(NetworkAttributes.network_transport()) | unquote(URLAttributes.url_scheme()) | unquote(URLAttributes.url_template()) | unquote(UserAgentAttributes.user_agent_original()) @type opt_in_attrs() :: [opt_in_attr()] @type options() :: [unquote(NimbleOptions.option_typespec(@options_schema))] @moduledoc """ Wraps a Req request in an opentelemetry span. Spans are not created until the request is completed or errored. ## Req Path Params It is strongly encouraged to use the [`put_path_params` step](https://hexdocs.pm/req/Req.Steps.html#put_path_params/1) option. This allows the span name to include the `{target}` portion of the span name described in the [HTTP Span Name guidelines](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name). > #### Requirements {: .info} > > * `path_params` option should be set along with a templated path. Only `:colon` style is supported > * `URLAttributes.url_template()` opt-in attribute must be set to `true` ## Semantic Conventions All available required and recommended [Client HTTP Span](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client) semantic conventions are implemented. Supported opt-in and experimental attributes can be configured using the `opt_in_attrs` option. ## Options ### Opt-in Semantic Convention Attributes Otel SemConv requires users to explicitly opt in for any attribute with a requirement level of `opt-in` or `experimental`. To ensure compatability, always use the SemConv attribute. Example: ``` client = Req.new() |> OpentelemetryReq.attach( opt_in_attrs: [SemConv.URLAttributes.url_template()] ) client |> Req.get( url: "/api/users/:user_id", path_params: [user_id: user_id] ) ``` Request and response header attributes are opt-in and can be set with the `request_header_attrs` and `response_header_attrs` options. Values should be lower-case. ### Trace Header Propagation By default, trace propagation headers are not injected to requests. There are two options available to propagate trace headers: * set `propagate_trace_headers` option to `true` when attaching or in the call * manually use `:otel_propagator_text_map.inject/1` Example: ``` client = Req.new() |> OpentelemetryReq.attach(propagate_trace_headers: true) # or client = Req.new() |> OpentelemetryReq.attach() Req.get(client, "/", propagate_trace_headers: true) ``` ### Span Name Override The span name can be overridden by setting the `span_name` option in the call. Example: ``` client = Req.new() |> OpentelemetryReq.attach() Req.get(client, "/", span_name: "custom") ``` > #### Option Precedence {: .info} > > Options passed in a request take precedence over those passed in `attach/1`. > """ @spec attach(Req.Request.t(), options()) :: Req.Request.t() def attach(%Req.Request{} = request, options \\ []) do config = options |> NimbleOptions.validate!(@options_schema) |> Enum.into(%{}) |> then(fn config -> if Enum.member?(config.opt_in_attrs, URLAttributes.url_template()) do Map.put(config, :url_template_enabled, true) else Map.put(config, :url_template_enabled, false) end end) request |> Req.Request.put_private(:otel, config) |> Req.Request.register_options([ :propagate_trace_headers, :request_header_attrs, :response_header_attrs, :span_name ]) |> Req.Request.append_request_steps( start_span: &start_span/1, put_trace_headers: &propagate_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 config = Req.Request.get_private(request, :otel) opt_in = %{ HTTPAttributes.http_response_body_size() => extract_response_body_size(response) } |> Map.take(config.opt_in_attrs) if response.status >= 400 do Tracer.set_status(OpenTelemetry.status(:error, "")) %{ HTTPAttributes.http_response_status_code() => response.status, ErrorAttributes.error_type() => to_string(response.status) } |> set_resp_header_attrs(response, request.options) |> Map.merge(opt_in) |> Tracer.set_attributes() else %{ HTTPAttributes.http_response_status_code() => response.status } |> set_resp_header_attrs(response, request.options) |> Map.merge(opt_in) |> Tracer.set_attributes() end OpenTelemetry.Tracer.end_span() Process.delete(:otel_parent_ctx) |> OpenTelemetry.Ctx.attach() {request, response} end defp end_errored_span({request, exception}) do Tracer.set_status(OpenTelemetry.status(:error, format_exception(exception))) Tracer.set_attributes(%{ ErrorAttributes.error_type() => exception.__struct__ }) Tracer.end_span() Process.delete(:otel_parent_ctx) |> 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 -> config = Req.Request.get_private(request, :otel) if config.span_name do config.span_name else method = parse_method(request.method) if config.url_template_enabled do case Req.Request.get_private(request, :path_params_template) do nil -> method params_template -> "#{method} #{params_template}" end else method end end span_name -> span_name end end defp build_req_attrs(request) do uri = request.url url = sanitize_url(uri) config = Req.Request.get_private(request, :otel) opt_in = %{ HTTPAttributes.http_request_body_size() => extract_request_body_size(request), NetworkAttributes.network_transport() => :tcp, URLAttributes.url_scheme() => extract_scheme(uri), URLAttributes.url_template() => extract_url_template(request), UserAgentAttributes.user_agent_original() => extract_user_agent(request) } %{ HTTPAttributes.http_request_method() => parse_method(request.method), ServerAttributes.server_address() => uri.host, ServerAttributes.server_port() => extract_port(uri), URLAttributes.url_full() => url } |> set_retry_count(request) |> set_req_header_attrs(request) |> then(fn attrs -> opt_in |> Map.take(config.opt_in_attrs) |> Map.merge(attrs) end) end defp sanitize_url(uri) do %{uri | userinfo: nil} |> URI.to_string() end defp extract_port(%{port: port}) when is_integer(port), do: port defp extract_port(%{scheme: scheme}) do case scheme do nil -> 80 "http" -> 80 "https" -> 443 _ -> 80 end end defp extract_scheme(%{scheme: scheme}) do case scheme do nil -> :http "http" -> :http "https" -> :https _ -> :http end end defp extract_url_template(request) do Req.Request.get_private(request, :path_params_template, "") end defp extract_user_agent(request) do case Req.Request.get_header(request, "user-agent") do [] -> "" [user_agent | _] -> user_agent end end defp extract_response_body_size(req) do case Req.Response.get_header(req, "content-length") do [] -> 0 [length_str | _] when is_binary(length_str) -> # Req sets this as a string but should be an integer # https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length # https://opentelemetry.io/docs/specs/semconv/attributes-registry/http String.to_integer(length_str) end end defp extract_request_body_size(req) do case Req.Request.get_header(req, "content-length") do [] -> 0 [length_str | _] when is_binary(length_str) -> # Req sets this as a string but should be an integer # https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length # https://opentelemetry.io/docs/specs/semconv/attributes-registry/http String.to_integer(length_str) end end defp set_retry_count(attrs, req) do retry_count = Req.Request.get_private(req, :req_retry_count, 0) if retry_count > 0 do Map.put(attrs, HTTPAttributes.http_request_resend_count(), retry_count) else attrs end end defp parse_method(method) do case method do :connect -> HTTPAttributes.http_request_method_values().connect :delete -> HTTPAttributes.http_request_method_values().delete :get -> HTTPAttributes.http_request_method_values().get :head -> HTTPAttributes.http_request_method_values().head :options -> HTTPAttributes.http_request_method_values().options :patch -> HTTPAttributes.http_request_method_values().patch :post -> HTTPAttributes.http_request_method_values().post :put -> HTTPAttributes.http_request_method_values().put :trace -> HTTPAttributes.http_request_method_values().trace end end defp propagate_trace_headers(request) do should_inject = Req.Request.get_option( request, :propagate_trace_headers, Req.Request.get_private(request, :otel)[:propagate_trace_headers] ) if should_inject do Req.Request.put_headers(request, :otel_propagator_text_map.inject([])) else request end end defp set_req_header_attrs(attrs, req) do Map.merge( attrs, :otel_http.extract_headers_attributes( :request, req.headers, Map.get(req.options, :request_header_attrs, []) ) ) end defp set_resp_header_attrs(attrs, resp, options) do Map.merge( attrs, :otel_http.extract_headers_attributes( :response, resp.headers, Map.get(options, :response_header_attrs, []) ) ) end end