Skip to content

Commit 9c52f44

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 9c52f44

File tree

5 files changed

+132
-7
lines changed

5 files changed

+132
-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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
default_port <- 443L
25+
if (parsed$scheme == "http") {
26+
default_port <- 80L
27+
}
28+
# Follow the semantic conventions and redact credentials in the URL, when
29+
# present.
30+
if (!is.null(parsed$username)) {
31+
parsed$username <- "REDACTED"
32+
}
33+
if (!is.null(parsed$password)) {
34+
parsed$password <- "REDACTED"
35+
}
36+
method <- req_method_get(req)
37+
span <- tracer$start_span(
38+
name = method,
39+
options = list(kind = "CLIENT"),
40+
# Ensure we set attributes relevant to sampling at span creation time.
41+
attributes = compact(list(
42+
"http.request.method" = method,
43+
"server.address" = parsed$hostname,
44+
"server.port" = parsed$port %||% default_port,
45+
"url.full" = url_build(parsed),
46+
"http.request.resend_count" = if (resend_count > 1) resend_count
47+
)),
48+
scope = scope
49+
)
50+
ctx <- span$get_context()
51+
req <- req_headers(req, !!!ctx$to_http_headers())
52+
req$state$span <- span
53+
req
54+
}
55+
56+
# Ends the Open Telemetry span associated with this request, if any.
57+
req_end_span <- function(req, resp = NULL) {
58+
span <- req$state$span
59+
if (is.null(span) || !span$is_recording()) {
60+
return()
61+
}
62+
if (is.null(resp)) {
63+
span$end()
64+
return()
65+
}
66+
if (is_error(resp)) {
67+
span$record_exception(resp)
68+
span$set_status("error")
69+
# Surface the underlying curl error class.
70+
span$set_attribute("error.type", class(resp$parent)[1])
71+
span$end()
72+
return()
73+
}
74+
span$set_attribute("http.response.status_code", resp_status(resp))
75+
if (error_is_error(req, resp)) {
76+
desc <- resp_status_desc(resp)
77+
if (is.na(desc)) {
78+
desc <- NULL
79+
}
80+
span$set_status("error", desc)
81+
# The semantic conventions recommend using the status code as a string for
82+
# these cases.
83+
span$set_attribute("error.type", as.character(resp_status(resp)))
84+
} else {
85+
span$set_status("ok")
86+
}
87+
span$end()
88+
}
89+
90+
# Replaces the existing Open Telemetry span on a request with a new one. Used
91+
# for retries.
92+
req_reset_span <- function(
93+
req,
94+
handle,
95+
resend_count = 0,
96+
tracer = NULL,
97+
scope = parent.frame()
98+
) {
99+
req <- req_with_span(req, resend_count, tracer, scope)
100+
if (is.null(req$state$span)) {
101+
return(req)
102+
}
103+
# Because the headers have changed, we need to re-sign the request and update
104+
# stateful components (like the handle).
105+
req <- auth_sign(req)
106+
curl::handle_setheaders(handle, .list = headers_flatten(req$headers))
107+
req$state$headers <- req$headers
108+
req
109+
}

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)