Skip to content

Commit 7a4aa28

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. 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 da2724a commit 7a4aa28

File tree

6 files changed

+232
-7
lines changed

6 files changed

+232
-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: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ req_perform <- function(
7979
verbosity <- verbosity %||% httr2_verbosity()
8080

8181
if (!is.null(mock)) {
82+
# Allow us to use with_mock() to confirm context propagation.
83+
req <- req_with_span(req)
8284
mock <- as_function(mock)
8385
mock_resp <- mock(req)
8486
if (!is.null(mock_resp)) {
@@ -93,6 +95,9 @@ req_perform <- function(
9395
return(req)
9496
}
9597

98+
sys_sleep(throttle_delay(req), "for throttling delay")
99+
100+
req <- req_with_span(req)
96101
req_prep <- req_prepare(req)
97102
handle <- req_handle(req_prep)
98103
max_tries <- retry_max_tries(req)
@@ -101,17 +106,19 @@ req_perform <- function(
101106
n <- 0
102107
tries <- 0
103108
reauthed <- FALSE # only ever re-authenticate once
104-
105-
sys_sleep(throttle_delay(req), "for throttling delay")
106-
107109
delay <- 0
108110
while (tries < max_tries && Sys.time() < deadline) {
109111
retry_check_breaker(req, tries, error_call = error_call)
110112
sys_sleep(delay, "for retry backoff")
111113
n <- n + 1
112114

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

116123
if (retry_is_transient(req, resp)) {
117124
tries <- tries + 1
@@ -270,7 +277,8 @@ req_handle <- function(req) {
270277

271278
handle
272279
}
273-
req_completed <- function(req) {
280+
req_completed <- function(req, resp = NULL) {
281+
req_end_span(req, resp)
274282
req_policy_call(req, "done", list(), NULL)
275283
}
276284

tests/testthat/test-req-perform.R

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,97 @@ test_that("checks input types", {
196196
req_perform(req, mock = 7)
197197
})
198198
})
199+
200+
test_that("tracing works as expected", {
201+
skip_if_not_installed("otelsdk")
202+
203+
spans <- otelsdk::with_otel_record({
204+
# A request with no URL (which shouldn't create a span).
205+
try(req_perform(request("")), silent = TRUE)
206+
207+
# A regular request.
208+
req_perform(request_test())
209+
210+
# A request with an HTTP error.
211+
try(
212+
req_perform(request_test("/status/:status", status = 404)),
213+
silent = TRUE
214+
)
215+
216+
# A request with basic credentials that we should redact.
217+
with_mocked_responses(
218+
function(req) {
219+
# Verify that the traceparent header is present when tracing while
220+
# we're in here.
221+
expect_false(is.null(req$headers$traceparent))
222+
response(status_code = 200)
223+
},
224+
req_perform(request("https://test:[email protected]"))
225+
)
226+
227+
# A request with a curl error.
228+
with_mocked_bindings(
229+
try(req_perform(request("http://127.0.0.1")), silent = TRUE),
230+
curl_fetch = function(...) abort("Failed to connect")
231+
)
232+
233+
# A request that triggers retries, generating three individual spans.
234+
request_test("/status/:status", status = 429) %>%
235+
req_retry(max_tries = 3, backoff = ~0) %>%
236+
req_perform() %>%
237+
try(silent = TRUE)
238+
})[["traces"]]
239+
240+
expect_length(spans, 7L)
241+
242+
# Validate the span for regular requests.
243+
expect_equal(spans[[1]]$status, "ok")
244+
expect_named(
245+
spans[[1]]$attributes,
246+
c(
247+
"http.response.status_code",
248+
"user_agent.original",
249+
"url.full",
250+
"server.address",
251+
"server.port",
252+
"http.request.method"
253+
)
254+
)
255+
expect_equal(spans[[1]]$attributes$http.request.method, "GET")
256+
expect_equal(spans[[1]]$attributes$http.response.status_code, 200L)
257+
expect_equal(spans[[1]]$attributes$server.address, "127.0.0.1")
258+
expect_match(spans[[1]]$attributes$user_agent.original, "^httr2/")
259+
260+
# And for requests with HTTP errors.
261+
expect_equal(spans[[2]]$status, "error")
262+
expect_equal(spans[[2]]$description, "Not Found")
263+
expect_equal(spans[[2]]$attributes$http.response.status_code, 404L)
264+
expect_equal(spans[[2]]$attributes$error.type, "404")
265+
266+
# And for spans with redacted credentials.
267+
expect_equal(spans[[3]]$attributes$server.address, "example.com")
268+
expect_equal(spans[[3]]$attributes$server.port, 443L)
269+
expect_equal(
270+
spans[[3]]$attributes$url.full,
271+
"https://REDACTED:[email protected]/"
272+
)
273+
274+
# And for spans with curl errors.
275+
expect_equal(spans[[4]]$status, "error")
276+
expect_equal(spans[[4]]$attributes$error.type, "rlang_error")
277+
278+
# We should have attached the curl error as an event.
279+
expect_length(spans[[4]]$events, 1L)
280+
expect_equal(spans[[4]]$events[[1]]$name, "exception")
281+
282+
# For spans with retries, we expect the parent context to be the same for
283+
# each span. (In this case, there is no parent span, so it should be empty.)
284+
# It is important that they not be children of one another.
285+
expect_equal(spans[[5]]$parent, "0000000000000000")
286+
expect_equal(spans[[6]]$parent, "0000000000000000")
287+
expect_equal(spans[[7]]$parent, "0000000000000000")
288+
289+
# Verify that we set resend counts correctly.
290+
expect_equal(spans[[6]]$attributes$http.request.resend_count, 2L)
291+
expect_equal(spans[[7]]$attributes$http.request.resend_count, 3L)
292+
})

0 commit comments

Comments
 (0)