Skip to content

Commit 1803b27

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). We also propagate the trace context [1] when there is one. 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. [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 1803b27

File tree

5 files changed

+136
-7
lines changed

5 files changed

+136
-7
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/otel.R

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Attaches an Open Telemetry span that abides by the semantic conventions for
2+
# HTTP clients to the request, including the associated W3C trace context
3+
# headers.
4+
#
5+
# See: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
6+
req_with_span <- function(
7+
req,
8+
resend_count = 0,
9+
tracer = NULL,
10+
scope = parent.frame()
11+
) {
12+
if (!is_installed("otel")) {
13+
return(req)
14+
}
15+
tracer <- tracer %||% otel::get_tracer("httr2")
16+
if (!tracer$is_enabled()) {
17+
return(req)
18+
}
19+
parsed <- tryCatch(url_parse(req$url), error = function(cnd) NULL)
20+
if (is.null(parsed)) {
21+
# Don't create spans for invalid URLs.
22+
return(req)
23+
}
24+
if (!req_has_user_agent(req)) {
25+
req <- req_user_agent(req)
26+
}
27+
default_port <- 443L
28+
if (parsed$scheme == "http") {
29+
default_port <- 80L
30+
}
31+
# Follow the semantic conventions and redact credentials in the URL, when
32+
# present.
33+
if (!is.null(parsed$username)) {
34+
parsed$username <- "REDACTED"
35+
}
36+
if (!is.null(parsed$password)) {
37+
parsed$password <- "REDACTED"
38+
}
39+
method <- req_method_get(req)
40+
span <- tracer$start_span(
41+
name = method,
42+
options = list(kind = "CLIENT"),
43+
# Ensure we set attributes relevant to sampling at span creation time.
44+
attributes = compact(list(
45+
"http.request.method" = method,
46+
"server.address" = parsed$hostname,
47+
"server.port" = parsed$port %||% default_port,
48+
"url.full" = url_build(parsed),
49+
"http.request.resend_count" = if (resend_count > 1) resend_count,
50+
"user_agent.original" = req$options$useragent
51+
)),
52+
scope = scope
53+
)
54+
ctx <- span$get_context()
55+
req <- req_headers(req, !!!ctx$to_http_headers())
56+
req$state$span <- span
57+
req
58+
}
59+
60+
# Ends the Open Telemetry span associated with this request, if any.
61+
req_end_span <- function(req, resp = NULL) {
62+
span <- req$state$span
63+
if (is.null(span) || !span$is_recording()) {
64+
return()
65+
}
66+
if (is.null(resp)) {
67+
span$end()
68+
return()
69+
}
70+
if (is_error(resp)) {
71+
span$record_exception(resp)
72+
span$set_status("error")
73+
# Surface the underlying curl error class.
74+
span$set_attribute("error.type", class(resp$parent)[1])
75+
span$end()
76+
return()
77+
}
78+
span$set_attribute("http.response.status_code", resp_status(resp))
79+
if (error_is_error(req, resp)) {
80+
desc <- resp_status_desc(resp)
81+
if (is.na(desc)) {
82+
desc <- NULL
83+
}
84+
span$set_status("error", desc)
85+
# The semantic conventions recommend using the status code as a string for
86+
# these cases.
87+
span$set_attribute("error.type", as.character(resp_status(resp)))
88+
} else {
89+
span$set_status("ok")
90+
}
91+
span$end()
92+
}
93+
94+
# Replaces the existing Open Telemetry span on a request with a new one. Used
95+
# for retries.
96+
req_reset_span <- function(
97+
req,
98+
handle,
99+
resend_count = 0,
100+
tracer = NULL,
101+
scope = parent.frame()
102+
) {
103+
req <- req_with_span(req, resend_count, tracer, scope)
104+
if (is.null(req$state$span)) {
105+
return(req)
106+
}
107+
# Because the headers have changed, we need to re-sign the request and update
108+
# stateful components (like the handle).
109+
req <- auth_sign(req)
110+
curl::handle_setheaders(handle, .list = headers_flatten(req$headers))
111+
req$state$headers <- req$headers
112+
req
113+
}

R/req-perform-connection.R

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
5454
# verbosity checked in req_verbosity_connection
5555

5656
req <- req_verbosity_connection(req, verbosity %||% httr2_verbosity())
57+
req <- req_with_span(req)
5758
req_prep <- req_prepare(req)
5859
handle <- req_handle(req_prep)
5960
the$last_request <- req
@@ -71,7 +72,14 @@ req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
7172
if (!is.null(resp)) {
7273
close(resp)
7374
}
75+
76+
if (tries != 0) {
77+
# Start a new span for retried requests.
78+
req_prep <- req_reset_span(req_prep, handle, resend_count = tries)
79+
}
80+
7481
resp <- req_perform_connection1(req, handle, blocking = blocking)
82+
req_completed(req_prep, resp)
7583

7684
if (retry_is_transient(req, resp)) {
7785
tries <- tries + 1
@@ -82,7 +90,6 @@ req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
8290
break
8391
}
8492
}
85-
req_completed(req)
8693

8794
if (!is_error(resp) && error_is_error(req, resp)) {
8895
# Read full body if there's an error

R/req-perform.R

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ req_perform <- function(
9393
return(req)
9494
}
9595

96+
sys_sleep(throttle_delay(req), "for throttling delay")
97+
98+
req <- req_with_span(req)
9699
req_prep <- req_prepare(req)
97100
handle <- req_handle(req_prep)
98101
max_tries <- retry_max_tries(req)
@@ -101,17 +104,19 @@ req_perform <- function(
101104
n <- 0
102105
tries <- 0
103106
reauthed <- FALSE # only ever re-authenticate once
104-
105-
sys_sleep(throttle_delay(req), "for throttling delay")
106-
107107
delay <- 0
108108
while (tries < max_tries && Sys.time() < deadline) {
109109
retry_check_breaker(req, tries, error_call = error_call)
110110
sys_sleep(delay, "for retry backoff")
111111
n <- n + 1
112112

113+
if (n != 1) {
114+
# Start a new span for retried requests.
115+
req_prep <- req_reset_span(req_prep, handle, resend_count = n)
116+
}
117+
113118
resp <- req_perform1(req, path = path, handle = handle)
114-
req_completed(req_prep)
119+
req_completed(req_prep, resp)
115120

116121
if (retry_is_transient(req, resp)) {
117122
tries <- tries + 1
@@ -270,7 +275,8 @@ req_handle <- function(req) {
270275

271276
handle
272277
}
273-
req_completed <- function(req) {
278+
req_completed <- function(req, resp = NULL) {
279+
req_end_span(req, resp)
274280
req_policy_call(req, "done", list(), NULL)
275281
}
276282

0 commit comments

Comments
 (0)