Skip to content

Commit 22a105e

Browse files
atherielgaborcsardi
andcommitted
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. The main subtlety is that I had to tweak some of httr2's internals so that request signing can take into account new headers. Luckily there is fairly comprehensive test coverage so I'm fairly sure at this point that I haven't broken anything. 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. Unit tests are included. [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]> Co-authored-by: Gábor Csárdi <[email protected]>
1 parent e0c71f7 commit 22a105e

File tree

11 files changed

+507
-18
lines changed

11 files changed

+507
-18
lines changed

DESCRIPTION

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Suggests:
3939
knitr,
4040
later (>= 1.4.0),
4141
nanonext,
42+
otel (>= 0.0.0.9000),
43+
otelsdk (>= 0.0.0.9000),
4244
paws.common,
4345
promises,
4446
rmarkdown,
@@ -56,4 +58,6 @@ Encoding: UTF-8
5658
Roxygen: list(markdown = TRUE)
5759
RoxygenNote: 7.3.2
5860
Remotes:
59-
r-lib/webfakes
61+
r-lib/webfakes,
62+
r-lib/otel,
63+
r-lib/otelsdk

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* `req_body_json_modify()` can now be used on a request with an empty body.
66
* `resp_timing()` exposes timing information about the request measured by libcurl (@arcresu, #725).
77
* `req_url_query()` now re-calculates n lengths when using `.multi = "explode"` to avoid select/recycling issues (@Kevanness, #719).
8+
* httr2 will now emit OpenTelemetry traces for all requests when tracing is enabled. Requires the `otelsdk` package (@atheriel, #729).
89

910
# httr2 1.1.2
1011

R/otel.R

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 = get_tracer(),
10+
scope = parent.frame(),
11+
active = TRUE
12+
) {
13+
if (!is_tracing(tracer)) {
14+
cli::cli_abort(
15+
"Cannot create request span; tracing is not enabled",
16+
.internal = TRUE
17+
)
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+
# Set required (and some recommended) attributes, especially those relevant to
41+
# sampling at span creation time.
42+
attributes <- compact(list(
43+
"http.request.method" = method,
44+
"server.address" = parsed$hostname,
45+
"server.port" = parsed$port %||% default_port,
46+
"url.full" = url_build(parsed),
47+
"http.request.resend_count" = if (resend_count > 1) resend_count,
48+
"user_agent.original" = req$options$useragent
49+
))
50+
if (active) {
51+
span <- tracer$start_span(
52+
name = method,
53+
options = list(kind = "CLIENT"),
54+
attributes = attributes,
55+
scope = scope
56+
)
57+
} else {
58+
span <- tracer$start_session(
59+
name = method,
60+
options = list(kind = "CLIENT"),
61+
attributes = attributes
62+
)
63+
}
64+
req <- req_headers(req, !!!otel::pack_http_context())
65+
req$state$span <- span
66+
req
67+
}
68+
69+
req_record_span_status <- function(req, resp = NULL) {
70+
span <- req$state$span
71+
if (is.null(span) || !span$is_recording()) {
72+
return()
73+
}
74+
# For more accurate span timing, we end the span after the response has been
75+
# received, rather than at the end of the associated scope.
76+
on.exit(span$end())
77+
if (is.null(resp)) {
78+
return()
79+
}
80+
if (is_error(resp)) {
81+
span$record_exception(resp)
82+
span$set_status("error")
83+
# Surface the underlying curl error class.
84+
span$set_attribute("error.type", class(resp$parent)[1])
85+
return()
86+
}
87+
span$set_attribute("http.response.status_code", resp_status(resp))
88+
if (error_is_error(req, resp)) {
89+
desc <- resp_status_desc(resp)
90+
if (is.na(desc)) {
91+
desc <- NULL
92+
}
93+
span$set_status("error", desc)
94+
# The semantic conventions recommend using the status code as a string for
95+
# these cases.
96+
span$set_attribute("error.type", as.character(resp_status(resp)))
97+
} else {
98+
span$set_status("ok")
99+
}
100+
}
101+
102+
get_tracer <- function() {
103+
if (!is.null(the$tracer)) {
104+
return(the$tracer)
105+
}
106+
if (!is_installed("otel")) {
107+
return(NULL)
108+
}
109+
if (is_testing()) {
110+
# Don't cache the tracer in unit tests. It interferes with tracer provider
111+
# injection in otelsdk::with_otel_record().
112+
return(otel::get_tracer("httr2"))
113+
}
114+
the$tracer <- otel::get_tracer("httr2")
115+
the$tracer
116+
}
117+
118+
is_tracing <- function(tracer = get_tracer()) {
119+
!is.null(tracer) && tracer$is_enabled()
120+
}

R/pooled-request.R

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ PooledRequest <- R6Class(
5454

5555
private$req_prep <- req_prepare(req)
5656
private$handle <- req_handle(private$req_prep)
57+
if (is_tracing()) {
58+
# Note: we need to do this before we call handle_preflight() so that
59+
# request signing works correctly with the added headers.
60+
#
61+
# TODO: Support resend_count.
62+
private$req_prep <- req_with_span(
63+
private$req_prep,
64+
# Pooled request spans should not become the active span; we want
65+
# subsequent requests to be siblings rather than parents.
66+
active = FALSE
67+
)
68+
}
69+
handle_preflight(private$req_prep, private$handle)
5770

5871
curl::multi_add(
5972
handle = private$handle,
@@ -71,6 +84,9 @@ PooledRequest <- R6Class(
7184
if (!is.null(private$handle)) {
7285
curl::multi_cancel(private$handle)
7386
}
87+
if (!is.null(private$req_prep)) {
88+
req_record_span_status(private$req_prep)
89+
}
7490
}
7591
),
7692
private = list(
@@ -101,6 +117,7 @@ PooledRequest <- R6Class(
101117
}
102118

103119
resp <- create_response(self$req, curl_data, body)
120+
req_record_span_status(private$req_prep, resp)
104121
resp <- cache_post_fetch(self$req, resp, path = private$path)
105122

106123
if (error_is_error(self$req, resp)) {
@@ -120,6 +137,7 @@ PooledRequest <- R6Class(
120137
curl_error <- error_cnd(message = msg, class = error_class, call = NULL)
121138
error <- curl_cnd(curl_error, call = private$error_call)
122139
error$request <- self$req
140+
req_record_span_status(private$req_prep, error)
123141
private$on_error(error)
124142
}
125143
)

R/req-dry-run.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ req_dry_run <- function(
6161

6262
req <- req_prepare(req)
6363
handle <- req_handle(req)
64-
curl::handle_setopt(handle, url = req$url)
64+
handle_preflight(req, handle)
6565
resp <- curl::curl_echo(handle, progress = FALSE)
6666

6767
if (!quiet) {

R/req-perform-connection.R

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
7171
if (!is.null(resp)) {
7272
close(resp)
7373
}
74-
resp <- req_perform_connection1(req, handle, blocking = blocking)
74+
resp <- req_perform_connection1(
75+
req,
76+
req_prep,
77+
handle,
78+
blocking = blocking,
79+
resend_count = tries + 1L
80+
)
7581

7682
if (retry_is_transient(req, resp)) {
7783
tries <- tries + 1
@@ -82,7 +88,7 @@ req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
8288
break
8389
}
8490
}
85-
req_completed(req)
91+
req_completed(req_prep)
8692

8793
if (!is_error(resp) && error_is_error(req, resp)) {
8894
# Read full body if there's an error
@@ -124,23 +130,38 @@ req_verbosity_connection <- function(
124130
req
125131
}
126132

127-
req_perform_connection1 <- function(req, handle, blocking = TRUE) {
133+
req_perform_connection1 <- function(
134+
req,
135+
req_prep,
136+
handle,
137+
blocking = TRUE,
138+
resend_count = 0
139+
) {
128140
the$last_request <- req
129141
the$last_response <- NULL
130142
signal(class = "httr2_perform_connection")
143+
if (is_tracing()) {
144+
# Note: we need to do this before we call handle_preflight() so that request
145+
# signing works correctly with the added headers.
146+
req_prep <- req_with_span(req_prep, resend_count = resend_count)
147+
}
148+
handle_preflight(req_prep, handle)
131149

132150
err <- capture_curl_error({
133151
body <- curl::curl(req$url, handle = handle)
134152
# Must open the stream in order to initiate the connection
135153
suppressWarnings(open(body, "rbf", blocking = blocking))
136154
})
137155
if (is_error(err)) {
156+
req_record_span_status(req, err)
138157
close(body)
139158
return(err)
140159
}
141160

142161
curl_data <- curl::handle_data(handle)
143-
create_response(req, curl_data, body)
162+
resp <- create_response(req, curl_data, body)
163+
req_record_span_status(req, resp)
164+
resp
144165
}
145166

146167
# Make open mockable

R/req-perform.R

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

113-
resp <- req_perform1(req, path = path, handle = handle)
113+
resp <- req_perform1(
114+
req,
115+
req_prep,
116+
path = path,
117+
handle = handle,
118+
resend_count = n
119+
)
114120
req_completed(req_prep)
115121

116122
if (retry_is_transient(req, resp)) {
@@ -174,23 +180,38 @@ resp_failure_cnd <- function(req, resp, error_call = caller_env()) {
174180
))
175181
}
176182

177-
req_perform1 <- function(req, path = NULL, handle = NULL) {
183+
req_perform1 <- function(
184+
req,
185+
req_prep,
186+
path = NULL,
187+
handle = NULL,
188+
resend_count = 0
189+
) {
178190
the$last_request <- req
179191
the$last_response <- NULL
180192
signal(class = "httr2_perform")
193+
if (is_tracing()) {
194+
# Note: we need to do this before we call handle_preflight() so that request
195+
# signing works correctly with the added headers.
196+
req_prep <- req_with_span(req_prep, resend_count = resend_count)
197+
}
198+
handle_preflight(req_prep, handle)
181199

182200
err <- capture_curl_error({
183201
fetch <- curl_fetch(handle, req$url, path)
184202
})
185203
if (is_error(err)) {
204+
req_record_span_status(req, err)
186205
return(err)
187206
}
188207

189208
# Ensure cookies are saved to disk now, not when request is finalised
190209
curl::handle_setopt(handle, cookielist = "FLUSH")
191210
curl::handle_setopt(handle, cookiefile = NULL, cookiejar = NULL)
192211

193-
create_response(req, fetch$curl_data, fetch$body)
212+
resp <- create_response(req, fetch$curl_data, fetch$body)
213+
req_record_span_status(req_prep, resp)
214+
resp
194215
}
195216

196217
curl_fetch <- function(handle, url, path) {
@@ -222,30 +243,34 @@ req_verbosity <- function(req, verbosity, error_call = caller_env()) {
222243
# Must call req_prepare(), then req_handle(), then after the request has been
223244
# performed, req_completed() (on the prepared requests)
224245
req_prepare <- function(req) {
225-
req <- auth_sign(req)
226246
req <- req_method_apply(req)
227247
req <- req_body_apply(req)
228-
229-
# Save actually request headers so that req_verbose() can use them
230-
req$state$headers <- req$headers
231-
232-
req
233-
}
234-
req_handle <- function(req) {
235248
if (!req_has_user_agent(req)) {
236249
req <- req_user_agent(req)
237250
}
251+
req
252+
}
238253

254+
req_handle <- function(req) {
239255
handle <- curl::new_handle()
240256
curl::handle_setopt(handle, url = req$url)
241-
curl::handle_setheaders(handle, .list = headers_flatten(req$headers))
242257
curl::handle_setopt(handle, .list = req$options)
243258
if (length(req$fields) > 0) {
244259
curl::handle_setform(handle, .list = req$fields)
245260
}
246261

247262
handle
248263
}
264+
265+
# Called right before the request is sent, when the final headers are in place.
266+
handle_preflight <- function(req, handle) {
267+
req <- auth_sign(req)
268+
curl::handle_setheaders(handle, .list = headers_flatten(req$headers))
269+
# Save final request headers so that req_verbose() can use them
270+
req$state$headers <- req$headers
271+
invisible(handle)
272+
}
273+
249274
req_completed <- function(req) {
250275
req_policy_call(req, "done", list(), NULL)
251276
}

0 commit comments

Comments
 (0)