Skip to content

Commit 0af3f57

Browse files
atherielshikokuchuo
authored 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]>
1 parent d5e64e6 commit 0af3f57

File tree

11 files changed

+513
-20
lines changed

11 files changed

+513
-20
lines changed

DESCRIPTION

Lines changed: 4 additions & 0 deletions
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.2.0),
43+
otelsdk (>= 0.2.0),
4244
paws.common,
4345
promises,
4446
rmarkdown,
@@ -55,3 +57,5 @@ Config/testthat/start-first: resp-stream, req-perform
5557
Encoding: UTF-8
5658
Roxygen: list(markdown = TRUE)
5759
RoxygenNote: 7.3.2
60+
Remotes:
61+
r-lib/otelsdk

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* Refactor `url_modify()` to better retain exact formatting of URL components
44
that are not modified. (#788, #794)
55

6+
* httr2 will now emit OpenTelemetry traces for all requests when tracing is
7+
enabled. Requires the `otelsdk` package (@atheriel, #729).
8+
69
# httr2 1.2.1
710

811
* Colons in paths are no longer escaped.

R/otel.R

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
activation_scope = parent.frame(),
11+
activate = 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_get_method(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+
span <- tracer$start_span(
51+
name = method,
52+
options = list(kind = "CLIENT"),
53+
attributes = attributes
54+
)
55+
if (activate) {
56+
span$activate(activation_scope, end_on_exit = TRUE)
57+
}
58+
req <- req_headers(req, !!!otel::pack_http_context())
59+
req$state$span <- span
60+
req
61+
}
62+
63+
req_record_span_status <- function(req, resp = NULL) {
64+
span <- req$state$span
65+
if (is.null(span) || !span$is_recording()) {
66+
return()
67+
}
68+
# For more accurate span timing, we end the span after the response has been
69+
# received, rather than at the end of the associated scope.
70+
on.exit(span$end())
71+
if (is.null(resp)) {
72+
return()
73+
}
74+
if (is_error(resp)) {
75+
span$record_exception(resp)
76+
span$set_status("error")
77+
# Surface the underlying curl error class.
78+
span$set_attribute("error.type", class(resp$parent)[1])
79+
return()
80+
}
81+
span$set_attribute("http.response.status_code", resp_status(resp))
82+
if (error_is_error(req, resp)) {
83+
desc <- resp_status_desc(resp)
84+
if (is.na(desc)) {
85+
desc <- NULL
86+
}
87+
span$set_status("error", desc)
88+
# The semantic conventions recommend using the status code as a string for
89+
# these cases.
90+
span$set_attribute("error.type", as.character(resp_status(resp)))
91+
} else {
92+
span$set_status("ok")
93+
}
94+
}
95+
96+
get_tracer <- function() {
97+
if (!is.null(the$tracer)) {
98+
return(the$tracer)
99+
}
100+
if (!is_installed("otel")) {
101+
return(NULL)
102+
}
103+
if (is_testing()) {
104+
# Don't cache the tracer in unit tests. It interferes with tracer provider
105+
# injection in otelsdk::with_otel_record().
106+
return(otel::get_tracer("httr2"))
107+
}
108+
the$tracer <- otel::get_tracer("httr2")
109+
the$tracer
110+
}
111+
112+
is_tracing <- function(tracer = get_tracer()) {
113+
!is.null(tracer) && tracer$is_enabled()
114+
}

R/pooled-request.R

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

6767
private$req_prep <- req_prepare(req)
6868
private$handle <- req_handle(private$req_prep)
69+
if (is_tracing()) {
70+
# Note: we need to do this before we call handle_preflight() so that
71+
# request signing works correctly with the added headers.
72+
#
73+
# TODO: Support resend_count.
74+
private$req_prep <- req_with_span(
75+
private$req_prep,
76+
# Pooled request spans should not become the active span; we want
77+
# subsequent requests to be siblings rather than parents.
78+
activate = FALSE
79+
)
80+
}
81+
handle_preflight(private$req_prep, private$handle)
6982

7083
curl::multi_add(
7184
handle = private$handle,
@@ -83,6 +96,9 @@ PooledRequest <- R6Class(
8396
if (!is.null(private$handle)) {
8497
curl::multi_cancel(private$handle)
8598
}
99+
if (!is.null(private$req_prep)) {
100+
req_record_span_status(private$req_prep)
101+
}
86102
}
87103
),
88104
private = list(
@@ -114,6 +130,7 @@ PooledRequest <- R6Class(
114130
}
115131

116132
resp <- create_response(self$req, curl_data, body)
133+
req_record_span_status(private$req_prep, resp)
117134
resp <- cache_post_fetch(self$req, resp, path = private$path)
118135
private$handle_response(resp, self$req)
119136
},
@@ -136,6 +153,7 @@ PooledRequest <- R6Class(
136153
curl_error <- error_cnd(message = msg, class = error_class, call = NULL)
137154
error <- curl_cnd(curl_error, call = private$error_call)
138155
error$request <- self$req
156+
req_record_span_status(private$req_prep, error)
139157
private$on_error(error)
140158
}
141159
)

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
headers <- new_headers(
6767
as.list(resp$headers),

R/req-perform-connection.R

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,13 @@ req_perform_connection <- function(
8484
if (!is.null(resp)) {
8585
close(resp)
8686
}
87-
resp <- req_perform_connection1(req, handle, blocking = blocking)
87+
resp <- req_perform_connection1(
88+
req,
89+
req_prep,
90+
handle,
91+
blocking = blocking,
92+
resend_count = tries + 1L
93+
)
8894

8995
if (retry_is_transient(req, resp)) {
9096
tries <- tries + 1
@@ -95,7 +101,7 @@ req_perform_connection <- function(
95101
break
96102
}
97103
}
98-
req_completed(req)
104+
req_completed(req_prep)
99105

100106
if (!is_error(resp) && error_is_error(req, resp)) {
101107
# Read full body if there's an error
@@ -135,10 +141,22 @@ req_verbosity_connection <- function(
135141
req
136142
}
137143

138-
req_perform_connection1 <- function(req, handle, blocking = TRUE) {
144+
req_perform_connection1 <- function(
145+
req,
146+
req_prep,
147+
handle,
148+
blocking = TRUE,
149+
resend_count = 0
150+
) {
139151
the$last_request <- req
140152
the$last_response <- NULL
141153
signal(class = "httr2_perform_connection")
154+
if (is_tracing()) {
155+
# Note: we need to do this before we call handle_preflight() so that request
156+
# signing works correctly with the added headers.
157+
req_prep <- req_with_span(req_prep, resend_count = resend_count)
158+
}
159+
handle_preflight(req_prep, handle)
142160

143161
err <- capture_curl_error({
144162
conn <- curl::curl(req$url, handle = handle)
@@ -151,11 +169,14 @@ req_perform_connection1 <- function(req, handle, blocking = TRUE) {
151169
body <- StreamingBody$new(conn)
152170
})
153171
if (is_error(err)) {
172+
req_record_span_status(req, err)
154173
return(err)
155174
}
156175

157176
curl_data <- curl::handle_data(handle)
158-
create_response(req, curl_data, body)
177+
resp <- create_response(req, curl_data, body)
178+
req_record_span_status(req, resp)
179+
resp
159180
}
160181

161182
# Make open mockable

R/req-perform.R

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

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

115121
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,33 +243,37 @@ 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(
242-
handle,
243-
.list = headers_flatten(req$headers, redact = FALSE)
244-
)
245257
curl::handle_setopt(handle, .list = req$options)
246258
if (length(req$fields) > 0) {
247259
curl::handle_setform(handle, .list = req$fields)
248260
}
249261

250262
handle
251263
}
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(
269+
handle,
270+
.list = headers_flatten(req$headers, redact = FALSE)
271+
)
272+
# Save final request headers so that req_verbose() can use them
273+
req$state$headers <- req$headers
274+
invisible(handle)
275+
}
276+
252277
req_completed <- function(req) {
253278
req_policy_call(req, "done", list(), NULL)
254279
}

0 commit comments

Comments
 (0)