opentelemetry-erlang-contrib/instrumentation/opentelemetry_req/lib/opentelemetry_req.ex

236 lines
5.7 KiB
Elixir

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 -> method
params_template -> "#{method} #{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
Map.put(request, :headers, :otel_propagator_text_map.inject(request.headers))
else
request
end
end
defp require_path_params_option(request) do
unless request.options[:no_path_params] do
unless Map.has_key?(request.options, :path_params) do
{Req.Request.halt(request), __MODULE__.PathParamsOptionError.new()}
else
request
end
else
request
end
end
defmodule PathParamsOptionError do
defexception [:message]
def new do
%__MODULE__{}
end
@impl true
def message(_) do
"req_path_params path parameter options must be provided"
end
end
end