Skip to content

Commit 95d19b1

Browse files
committed
Add basic Open Telemetry instrumentation for all requests.
This commit wraps all requests in an Open Telemetry span that abides by the semantic conventions for HTTP clients [0] (insofar as I understand them). Right now this instrumentation is opt in: `otel` is in `Suggests`, and tracing must be enabled (e.g. via the `OTEL_TRACES_EXPORTER` environment variable). Otherwise this is costless at runtime. For example: library(otelsdk) Sys.setenv(OTEL_TRACES_EXPORTER = "stderr") request("https://google.com") |> req_perform() I'm not sure that `otel` needs to move to `Imports`, because by design users actually need the `otelsdk` package to enable tracing anyway. The major limitation right now is that we don't propagate the trace context to the server [1], because `otel` doesn't have an explicit mechanism for this yet. [0]: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span [1]: https://www.w3.org/TR/trace-context/ Signed-off-by: Aaron Jacobs <[email protected]>
1 parent da2724a commit 95d19b1

File tree

4 files changed

+75
-1
lines changed

4 files changed

+75
-1
lines changed

DESCRIPTION

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Suggests:
3939
knitr,
4040
later (>= 1.4.0),
4141
nanonext,
42+
otel (>= 0.0.0.9000),
4243
paws.common,
4344
promises,
4445
rmarkdown,
@@ -56,4 +57,5 @@ Encoding: UTF-8
5657
Roxygen: list(markdown = TRUE)
5758
RoxygenNote: 7.3.2
5859
Remotes:
59-
r-lib/webfakes
60+
r-lib/webfakes,
61+
r-lib/otel

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# httr2 (development version)
22

33
* `req_url_query()` now re-calculates n lengths when using `.multi = "explode"` to avoid select/recycling issues (@Kevanness, #719).
4+
* httr2 will now emit OpenTelemetry traces for all requests when tracing is enabled. Requires the `otelsdk` package (@atheriel, #729).
45

56
# httr2 1.1.2
67

R/req-perform-connection.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
7171
if (!is.null(resp)) {
7272
close(resp)
7373
}
74+
span <- start_span(req, tries)
7475
resp <- req_perform_connection1(req, handle, blocking = blocking)
76+
# Note: this is a bit misleading. We might not want to end the span until
77+
# the response has been read to completion.
78+
end_span(span, req, resp)
7579

7680
if (retry_is_transient(req, resp)) {
7781
tries <- tries + 1

R/req-perform.R

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,11 @@ req_perform <- function(
110110
sys_sleep(delay, "for retry backoff")
111111
n <- n + 1
112112

113+
# TODO: This does not yet handle trace context propagation.
114+
span <- start_span(req, tries)
113115
resp <- req_perform1(req, path = path, handle = handle)
114116
req_completed(req_prep)
117+
end_span(span, req, resp)
115118

116119
if (retry_is_transient(req, resp)) {
117120
tries <- tries + 1
@@ -280,3 +283,67 @@ is_path <- function(x) inherits(x, "httr2_path")
280283
resp_show_body <- function(resp) {
281284
resp$request$policies$show_body %||% FALSE
282285
}
286+
287+
# Starts an Open Telemetry span that abides by the semantic conventions for
288+
# HTTP clients.
289+
#
290+
# See: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
291+
start_span <- function(req, tries, tracer = NULL, scope = parent.frame()) {
292+
if (!is_installed("otel")) {
293+
return(NULL)
294+
}
295+
tracer <- tracer %||% otel::get_tracer("httr2")
296+
if (!tracer$is_enabled()) {
297+
# Return a no-op span when tracing is disabled.
298+
return(tracer$start_span())
299+
}
300+
method <- req$method %||% "GET"
301+
parsed <- url_parse(req$url)
302+
default_port <- 443L
303+
if (parsed$scheme == "http") {
304+
default_port <- 80L
305+
}
306+
# Follow the semantic conventions and redact credentials in the URL, when
307+
# present.
308+
if (!is.null(parsed$username)) {
309+
parsed$username <- "REDACTED"
310+
}
311+
if (!is.null(parsed$password)) {
312+
parsed$password <- "REDACTED"
313+
}
314+
tracer$start_span(
315+
name = method,
316+
options = list(kind = "CLIENT"),
317+
# Ensure we set attributes relevant to sampling at span creation time.
318+
attributes = compact(list(
319+
"http.request.method" = method,
320+
"server.address" = parsed$hostname,
321+
"server.port" = parsed$port %||% default_port,
322+
"url.full" = url_build(parsed),
323+
"http.request.resend_count" = if (tries > 1) tries
324+
)),
325+
scope = scope
326+
)
327+
}
328+
329+
end_span <- function(span, req, resp) {
330+
if (is.null(span) || !span$is_recording()) {
331+
return(invisible(span))
332+
}
333+
if (is_error(resp)) {
334+
span$set_status("error")
335+
# Surface the underlying curl error class.
336+
span$set_attribute("error.type", class(resp$parent)[1])
337+
return(span$end())
338+
}
339+
span$set_attribute("http.response.status_code", resp_status(resp))
340+
if (error_is_error(req, resp)) {
341+
span$set_status("error", resp_status_desc(resp))
342+
# The semantic conventions recommend using the status code as a string for
343+
# these cases.
344+
span$set_attribute("error.type", as.character(resp_status(resp)))
345+
} else {
346+
span$set_status("ok")
347+
}
348+
span$end()
349+
}

0 commit comments

Comments
 (0)