Skip to content

Commit 2e0097a

Browse files
authored
Add OpenTelemetry instrumentation (#2282)
* OpenTelemetry instrumentation concept * CI: skip otelsdk installation on older platforms * Update otel tracer caching implementation * Move instrumentation to within `test_code()` * Also set test results as attributes * Add tests for nested cases / failures * Silent reporter * Add news item
1 parent bec8f61 commit 2e0097a

File tree

7 files changed

+213
-2
lines changed

7 files changed

+213
-2
lines changed

.github/workflows/R-CMD-check.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ jobs:
5858

5959
- uses: r-lib/actions/setup-r-dependencies@v2
6060
with:
61-
extra-packages: any::rcmdcheck
61+
extra-packages: |
62+
any::rcmdcheck
63+
otelsdk=?ignore-before-r=4.3.0
6264
needs: check
6365

6466
- uses: r-lib/actions/check-r-package@v2

DESCRIPTION

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Suggests:
4242
digest (>= 0.6.33),
4343
gh,
4444
knitr,
45+
otel,
46+
otelsdk,
4547
rmarkdown,
4648
rstudioapi,
4749
S7,

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
* Fixed support for `shinytest2::AppDriver$expect_values()` screenshot snapshot failing on CI (#2293, #2288).
44

5+
* testthat now emits OpenTelemetry traces for tests when tracing is enabled. Requires the otel and otelsdk packages (#2282).
6+
57
# testthat 3.3.0
68

79
## Lifecycle changes

R/otel.R

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
otel_tracer_name <- "org.r-lib.testthat"
2+
3+
# generic otel helpers ---------------------------------------------------------
4+
5+
otel_cache_tracer <- NULL
6+
otel_local_test_span <- NULL
7+
otel_update_span <- NULL
8+
9+
local({
10+
otel_is_tracing <- FALSE
11+
otel_tracer <- NULL
12+
13+
otel_cache_tracer <<- function() {
14+
requireNamespace("otel", quietly = TRUE) || return()
15+
otel_tracer <<- otel::get_tracer(otel_tracer_name)
16+
otel_is_tracing <<- tracer_enabled(otel_tracer)
17+
}
18+
19+
otel_local_test_span <<- function(name, scope = parent.frame()) {
20+
otel_is_tracing || return()
21+
otel::start_local_active_span(
22+
sprintf("test that %s", name),
23+
tracer = otel_tracer,
24+
activation_scope = scope
25+
)
26+
}
27+
28+
otel_update_span <<- function(
29+
span,
30+
n_success,
31+
n_failure,
32+
n_error,
33+
n_skip,
34+
n_warning
35+
) {
36+
otel_is_tracing || return()
37+
38+
total <- n_success + n_failure + n_error + n_skip + n_warning
39+
test_status <- if (n_error > 0) {
40+
"error"
41+
} else if (n_failure > 0) {
42+
"fail"
43+
} else if (total == 0 || n_skip == total) {
44+
"skip"
45+
} else {
46+
"pass"
47+
}
48+
span$set_attribute("test.expectations.total", total)
49+
span$set_attribute("test.expectations.passed", n_success)
50+
span$set_attribute("test.expectations.failed", n_failure)
51+
span$set_attribute("test.expectations.error", n_error)
52+
span$set_attribute("test.expectations.skipped", n_skip)
53+
span$set_attribute("test.expectations.warning", n_warning)
54+
span$set_attribute("test.status", test_status)
55+
56+
if (test_status %in% c("pass", "skip")) {
57+
span$set_status("ok")
58+
} else {
59+
span$set_status("error", paste("Test", test_status))
60+
}
61+
}
62+
})
63+
64+
tracer_enabled <- function(tracer) {
65+
.subset2(tracer, "is_enabled")()
66+
}
67+
68+
with_otel_record <- function(expr) {
69+
on.exit(otel_cache_tracer())
70+
otelsdk::with_otel_record({
71+
otel_cache_tracer()
72+
expr
73+
})
74+
}

R/test-that.R

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,27 @@ test_code <- function(code, env, reporter = NULL, skip_on_empty = TRUE) {
4949

5050
frame <- caller_env()
5151

52+
otel_n_success <- 0L
53+
otel_n_failure <- 0L
54+
otel_n_error <- 0L
55+
otel_n_skip <- 0L
56+
otel_n_warning <- 0L
57+
5258
test <- test_description()
5359
if (!is.null(test)) {
60+
span <- otel_local_test_span(test, scope = frame)
5461
reporter$start_test(context = reporter$.context, test = test)
55-
withr::defer(reporter$end_test(context = reporter$.context, test = test))
62+
withr::defer({
63+
otel_update_span(
64+
span,
65+
otel_n_success,
66+
otel_n_failure,
67+
otel_n_error,
68+
otel_n_skip,
69+
otel_n_warning
70+
)
71+
reporter$end_test(context = reporter$.context, test = test)
72+
})
5673
}
5774

5875
if (the$top_level_test) {
@@ -82,6 +99,17 @@ test_code <- function(code, env, reporter = NULL, skip_on_empty = TRUE) {
8299

83100
e$test <- test %||% "(code run outside of `test_that()`)"
84101

102+
# record keeping for otel
103+
switch(
104+
expectation_type(e),
105+
success = otel_n_success <<- otel_n_success + 1L,
106+
failure = otel_n_failure <<- otel_n_failure + 1L,
107+
error = otel_n_error <<- otel_n_error + 1L,
108+
skip = otel_n_skip <<- otel_n_skip + 1L,
109+
warning = otel_n_warning <<- otel_n_warning + 1L,
110+
NULL
111+
)
112+
85113
ok <<- ok && expectation_ok(e)
86114
reporter$add_result(context = reporter$.context, test = test, result = e)
87115
}

R/testthat-package.R

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,11 @@ the$in_check_reporter <- FALSE
3030
#' @importFrom lifecycle deprecated
3131
## usethis namespace: end
3232
NULL
33+
34+
# nocov start
35+
36+
.onLoad <- function(libname, pkgname) {
37+
otel_cache_tracer()
38+
}
39+
40+
# nocov end

tests/testthat/test-otel.R

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
test_that("otel instrumentation works", {
2+
skip_if_not_installed("otelsdk")
3+
4+
record <- with_otel_record({
5+
test_that("testing is traced", {
6+
expect_equal(1, 1)
7+
expect_error(stop("expected error"))
8+
})
9+
test_that("all expectations are recorded", {
10+
expect_equal(1, 1)
11+
expect_true(TRUE)
12+
expect_length(1:3, 3)
13+
expect_warning(warning("expected warning"))
14+
expect_error(stop("expected error"))
15+
})
16+
})
17+
18+
traces <- record$traces
19+
expect_length(traces, 2L)
20+
span <- traces[[1L]]
21+
expect_equal(
22+
span$name,
23+
"test that otel instrumentation works / testing is traced"
24+
)
25+
expect_equal(span$instrumentation_scope$name, "org.r-lib.testthat")
26+
span <- traces[[2L]]
27+
expect_equal(span$attributes[["test.status"]], "pass")
28+
expect_equal(span$attributes[["test.expectations.total"]], 5)
29+
expect_equal(span$attributes[["test.expectations.passed"]], 5)
30+
expect_equal(span$attributes[["test.expectations.failed"]], 0)
31+
expect_equal(span$attributes[["test.expectations.error"]], 0)
32+
expect_equal(span$attributes[["test.expectations.skipped"]], 0)
33+
expect_equal(span$attributes[["test.expectations.warning"]], 0)
34+
expect_equal(span$status, "ok")
35+
})
36+
37+
test_that("otel instrumentation works with describe/it", {
38+
skip_if_not_installed("otelsdk")
39+
40+
record <- with_otel_record({
41+
with_reporter("silent", {
42+
describe("a feature", {
43+
it("passes", {
44+
expect_true(TRUE)
45+
})
46+
it("fails", {
47+
expect_equal(1, 1)
48+
expect_true(FALSE)
49+
})
50+
})
51+
})
52+
})
53+
54+
traces <- record$traces
55+
expect_length(traces, 3L)
56+
expect_equal(traces[[1L]]$name, "test that a feature / passes")
57+
expect_equal(traces[[1L]]$attributes[["test.expectations.total"]], 1)
58+
expect_equal(traces[[1L]]$attributes[["test.status"]], "pass")
59+
expect_equal(traces[[1L]]$status, "ok")
60+
expect_equal(traces[[2L]]$name, "test that a feature / fails")
61+
expect_equal(traces[[2L]]$attributes[["test.expectations.total"]], 2)
62+
expect_equal(traces[[2L]]$attributes[["test.expectations.passed"]], 1)
63+
expect_equal(traces[[2L]]$attributes[["test.expectations.failed"]], 1)
64+
expect_equal(traces[[2L]]$attributes[["test.status"]], "fail")
65+
expect_equal(traces[[2L]]$status, "error")
66+
expect_equal(traces[[3L]]$name, "test that a feature")
67+
expect_equal(traces[[3L]]$attributes[["test.expectations.total"]], 0)
68+
})
69+
70+
test_that("otel instrumentation works with nested test_that", {
71+
skip_if_not_installed("otelsdk")
72+
73+
record <- with_otel_record({
74+
with_reporter("silent", {
75+
test_that("outer test", {
76+
expect_true(TRUE)
77+
test_that("inner test fails", {
78+
expect_equal(1, 2)
79+
})
80+
})
81+
})
82+
})
83+
84+
traces <- record$traces
85+
expect_length(traces, 2L)
86+
expect_equal(traces[[1L]]$name, "test that outer test / inner test fails")
87+
expect_equal(traces[[1L]]$attributes[["test.expectations.total"]], 1)
88+
expect_equal(traces[[1L]]$attributes[["test.expectations.failed"]], 1)
89+
expect_equal(traces[[1L]]$attributes[["test.status"]], "fail")
90+
expect_equal(traces[[1L]]$status, "error")
91+
expect_equal(traces[[2L]]$name, "test that outer test")
92+
expect_equal(traces[[2L]]$attributes[["test.expectations.total"]], 1)
93+
expect_equal(traces[[2L]]$attributes[["test.status"]], "pass")
94+
expect_equal(traces[[2L]]$status, "ok")
95+
})

0 commit comments

Comments
 (0)