Skip to content

Commit 676c24d

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. Span attributes are drawn from the semantic conventions [0]. 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. [0]: https://opentelemetry.io/docs/specs/semconv/registry/attributes/test/ Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent 30f5b11 commit 676c24d

21 files changed

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

β€Ž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.

0 commit comments

Comments
Β (0)