Skip to content

Commit f1cd6ce

Browse files
committed
Add a reporter that emits test-related Open Telemetry spans.
This commit introduces a variant of the Check reporter that records Open Telemetry traces for package tests. At present it records a parent span for each context/file and child spans for each test case. It also makes some effort to work out the package's GitHub URL so that we can set VCS-related attributes. Note that this uses the existing session API from the `otel` package to allow parallel testing, but this API has known issues. Basic unit tests are included. Signed-off-by: Aaron Jacobs <[email protected]>
1 parent 30f5b11 commit f1cd6ce

21 files changed

+416
-0
lines changed

β€ŽDESCRIPTION

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Suggests:
4141
curl (>= 0.9.5),
4242
diffviewer (>= 0.1.0),
4343
knitr,
44+
otel (>= 0.0.0.9000),
45+
otelsdk (>= 0.0.0.9000),
4446
rmarkdown,
4547
rstudioapi,
4648
S7,
@@ -57,3 +59,6 @@ Config/testthat/start-first: watcher, parallel*
5759
Encoding: UTF-8
5860
Roxygen: list(markdown = TRUE, r6 = FALSE)
5961
RoxygenNote: 7.3.2.9000
62+
Remotes:
63+
r-lib/otel,
64+
r-lib/otelsdk

β€ŽNAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export(ListReporter)
4040
export(LocationReporter)
4141
export(MinimalReporter)
4242
export(MultiReporter)
43+
export(OpenTelemetryReporter)
4344
export(ParallelProgressReporter)
4445
export(ProgressReporter)
4546
export(RStudioReporter)

β€ŽR/reporter-otel.R

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
#' OpenTelemetry reporter: traces for test results
2+
#'
3+
#' A variant of the Check reporter that also emits OpenTelemetry traces for
4+
#' tests.
5+
#'
6+
#' @export
7+
#' @family reporters
8+
OpenTelemetryReporter <- R6::R6Class(
9+
"OpenTelemetryReporter",
10+
inherit = CheckReporter,
11+
public = list(
12+
tracer = NULL,
13+
sessions = NULL,
14+
suite_spans = NULL,
15+
test_spans = NULL,
16+
current_file = NULL,
17+
18+
#' @param pkg A path to an R package, by default the one in the current
19+
#' directory.
20+
#' @param tracer An \pkg{otel} tracer, or `NULL` to use the default tracer.
21+
initialize = function(pkg = ".", tracer = NULL, ..., call = caller_env()) {
22+
check_installed("otel", "for emitting Open Telemetry traces", call = call)
23+
set_pkg_resource_attributes(pkg)
24+
self$tracer <- tracer %||% otel::get_tracer("testthat")
25+
self$sessions <- new_environment()
26+
self$suite_spans <- new_environment()
27+
self$test_spans <- new_environment()
28+
super$initialize(...)
29+
},
30+
31+
start_file = function(file) {
32+
# Track the current file so we can set it as an attribute on spans.
33+
self$current_file <- file.path("tests/testthat", file)
34+
context_start_file(file)
35+
},
36+
37+
end_file = function(file) {
38+
self$current_file <- NULL
39+
},
40+
41+
start_context = function(context) {
42+
# In order to handle concurrency issues with parallel tests, we maintain
43+
# an otel session for each context and switch in and out of it as needed.
44+
session <- self$tracer$start_session()
45+
env_poke(self$sessions, context, session)
46+
on.exit(self$tracer$deactivate_session(session))
47+
48+
span <- self$tracer$start_span(
49+
name = "test_suite",
50+
attributes = compact(list(
51+
"test.suite.name" = context,
52+
"code.filepath" = self$current_file
53+
)),
54+
scope = NULL
55+
)
56+
env_poke(self$suite_spans, context, span)
57+
},
58+
59+
end_context = function(context) {
60+
span <- env_get(self$suite_spans, context)
61+
span$end()
62+
env_unbind(self$suite_spans, context)
63+
64+
# Clean up the session.
65+
session <- env_get(self$sessions, context)
66+
self$tracer$finish_session(session)
67+
env_unbind(self$sessions, context)
68+
},
69+
70+
start_test = function(context, test) {
71+
if (is.null(context)) {
72+
# It seems like this can happen when running tests with a filter.
73+
context <- names(self$sessions)[1]
74+
}
75+
76+
# Ensure we start test spans (and any spans started by functions within
77+
# that test) in the context's session.
78+
session <- env_get(self$sessions, context)
79+
self$tracer$activate_session(session)
80+
81+
key <- paste(context, test, sep = "|")
82+
parent <- env_get(self$suite_spans, context)
83+
span <- self$tracer$start_span(
84+
name = "test_case",
85+
attributes = list("test.case.name" = test),
86+
options = list(parent = parent),
87+
scope = NULL
88+
)
89+
env_poke(self$test_spans, key, span)
90+
},
91+
92+
end_test = function(context, test) {
93+
if (is.null(context)) {
94+
# It seems like this can happen when running tests with a filter.
95+
context <- names(self$sessions)[1]
96+
}
97+
98+
# Deactivate the context's session before starting the next test (which
99+
# might have a different one).
100+
session <- env_get(self$sessions, context)
101+
on.exit(self$tracer$deactivate_session(session))
102+
103+
key <- paste(context, test, sep = "|")
104+
span <- env_get(self$test_spans, key)
105+
if (!span$status_set) {
106+
# If the span's status hasn't been set, we assume the test passed.
107+
span$set_status("ok")
108+
}
109+
span$end()
110+
env_unbind(self$test_spans, key)
111+
},
112+
113+
add_result = function(context, test, result) {
114+
if (expectation_broken(result) || expectation_skip(result)) {
115+
# Extract source references, if possible.
116+
filename <- NULL
117+
line <- NULL
118+
column <- NULL
119+
if (inherits(result$srcref, "srcref")) {
120+
filename <- attr(result$srcref, "srcfile")$filename
121+
line <- result$srcref[1]
122+
column <- result$srcref[2]
123+
}
124+
attributes <- compact(list(
125+
"code.filepath" = file.path("tests/testthat", filename),
126+
"code.lineno" = line,
127+
"code.column" = column
128+
))
129+
130+
if (is.null(context)) {
131+
context <- names(self$sessions)[1]
132+
}
133+
key <- paste(context, test, sep = "|")
134+
span <- env_get(self$test_spans, key)
135+
if (expectation_broken(result)) {
136+
# Record error or failure expectations as exceptions on the test span.
137+
span$record_exception(result, attributes = attributes)
138+
span$set_status("error")
139+
} else if (expectation_skip(result)) {
140+
# Record error or failure expectations as exceptions on the test span.
141+
span$add_event("test_skipped", attributes = attributes)
142+
}
143+
}
144+
super$add_result(context, test, result)
145+
}
146+
)
147+
)
148+
149+
set_pkg_resource_attributes <- function(pkg = ".") {
150+
attributes <- get_pkg_resource_attributes(pkg)
151+
if (is.null(attributes)) {
152+
return()
153+
}
154+
set_resource_attributes(.attributes = attributes)
155+
}
156+
157+
get_pkg_resource_attributes <- function(pkg = ".") {
158+
# Try to detect when we are testing a package.
159+
if (!env_var_is_true("NOT_CRAN")) {
160+
return(NULL)
161+
}
162+
# Use what we know about the package to set some resource attributes.
163+
desc <- pkgload::pkg_desc(pkg)
164+
attributes <- list(
165+
"service.name" = desc$get_field("Package"),
166+
"service.version" = desc$get_version(),
167+
"vcs.repository.url.full" = get_repo_url(),
168+
"vcs.repository.ref.revision" = get_git_revision()
169+
)
170+
# Existing environment variables take precedence.
171+
from_env <- get_resource_attributes()
172+
utils::modifyList(attributes, from_env)
173+
}
174+
175+
get_repo_url <- function(pkg = ".") {
176+
# Default to using the Github Actions context, if available.
177+
if (nchar(repo <- Sys.getenv("GITHUB_REPOSITORY")) != 0) {
178+
return(paste0(Sys.getenv("GITHUB_SERVER_URL"), "/", repo))
179+
}
180+
# Otherwise check if the package has a GitHub URL in its DESCRIPTION file.
181+
desc <- pkgload::pkg_desc(pkg)
182+
github_urls <- startsWith(desc$get_urls(), "https://github.com")
183+
if (any(github_urls)) {
184+
return(desc$get_urls()[github_urls][1])
185+
}
186+
NULL
187+
}
188+
189+
get_git_revision <- function() {
190+
# Default to using the Github Actions context, if available.
191+
if (nchar(revision <- Sys.getenv("GITHUB_SHA")) != 0) {
192+
return(revision)
193+
}
194+
tryCatch(
195+
system2(
196+
"git",
197+
c("rev-parse", "HEAD"),
198+
stdout = TRUE,
199+
stderr = TRUE
200+
)[1],
201+
error = function(e) NULL
202+
)
203+
}
204+
205+
get_resource_attributes <- function(
206+
env = Sys.getenv("OTEL_RESOURCE_ATTRIBUTES")
207+
) {
208+
if (nchar(env) == 0) {
209+
return(list())
210+
}
211+
# Split the attributes by comma and then by equals sign.
212+
attrs <- strsplit(env, ",", fixed = TRUE)[[1]]
213+
split <- strsplit(attrs, "=", fixed = TRUE)
214+
out <- structure(
215+
vector("list", length(split)),
216+
.Names = character(length(split))
217+
)
218+
for (i in seq_along(split)) {
219+
x <- split[[i]]
220+
if (length(x) != 2) {
221+
cli::cli_abort(
222+
"Invalid {.env OTEL_RESOURCE_ATTRIBUTES} format: {.str {env}}",
223+
.internal = TRUE
224+
)
225+
}
226+
out[[i]] <- x[2]
227+
names(out)[i] <- x[1]
228+
}
229+
out
230+
}
231+
232+
set_resource_attributes <- function(..., .attributes = list()) {
233+
attrs <- utils::modifyList(list(...), .attributes)
234+
# Special handling for service.name, which isn't picked up by the SDK unless
235+
# it's set in the dedicated environment variable.
236+
if (!is.null(attrs["service.name"])) {
237+
Sys.setenv(OTEL_SERVICE_NAME = attrs[["service.name"]])
238+
}
239+
attrs <- vapply(attrs, format, character(1L))
240+
formatted <- paste(names(attrs), attrs, sep = "=", collapse = ",")
241+
Sys.setenv(OTEL_RESOURCE_ATTRIBUTES = formatted)
242+
}

β€Žman/CheckReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/DebugReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/FailReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/JunitReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/ListReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/LocationReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/MinimalReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/MultiReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/OpenTelemetryReporter.Rd

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/ProgressReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/RStudioReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/Reporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žman/SilentReporter.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
Β (0)